Compare commits
32 Commits
feature-sc
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
7202376123 | |
|
|
f7dba437cf | |
|
|
68369b64de | |
|
|
24534ccb3e | |
|
|
7b00d21a34 | |
|
|
d955ac80f1 | |
|
|
162e5d699d | |
|
|
0fd028a49f | |
|
|
cc7ee58006 | |
|
|
ae9f0b3c62 | |
|
|
f8c1738aa2 | |
|
|
ebf76a0f8f | |
|
|
2c6faadcf2 | |
|
|
a6a98c7137 | |
|
|
01c1cacb84 | |
|
|
a75ae34428 | |
|
|
395b4dbbfb | |
|
|
7426286fa6 | |
|
|
7da6ab6ec0 | |
|
|
c72736c334 | |
|
|
ce79cb5d04 | |
|
|
d7a649809f | |
|
|
41087b5fda | |
|
|
6fba9c5362 | |
|
|
d0334a5575 | |
|
|
c705ce40f8 | |
|
|
8fe0512608 | |
|
|
a1192193e5 | |
|
|
cb267192d2 | |
|
|
a0c352f567 | |
|
|
6d09d25df7 | |
|
|
b0cebb97ef |
|
|
@ -50,3 +50,6 @@ logs/
|
||||||
*.yml
|
*.yml
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
|
||||||
|
zzz/
|
||||||
|
credentials/service_account.json
|
||||||
|
|
@ -24,6 +24,11 @@ async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
await create_db_tables()
|
await create_db_tables()
|
||||||
logger.info("Database tables created (DEBUG mode)")
|
logger.info("Database tables created (DEBUG mode)")
|
||||||
|
|
||||||
|
# dashboard 테이블 초기화 및 기존 데이터 마이그레이션 (모든 환경)
|
||||||
|
from app.dashboard.migration import init_dashboard_table
|
||||||
|
await init_dashboard_table()
|
||||||
|
|
||||||
await NvMapPwScraper.initiate_scraper()
|
await NvMapPwScraper.initiate_scraper()
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.error("Database initialization timed out")
|
logger.error("Database initialization timed out")
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,23 @@ def add_exception_handlers(app: FastAPI):
|
||||||
content=content,
|
content=content,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# DashboardException 핸들러 추가
|
||||||
|
from app.dashboard.exceptions import DashboardException
|
||||||
|
|
||||||
|
@app.exception_handler(DashboardException)
|
||||||
|
def dashboard_exception_handler(request: Request, exc: DashboardException) -> Response:
|
||||||
|
if exc.status_code < 500:
|
||||||
|
logger.warning(f"Handled DashboardException: {exc.__class__.__name__} - {exc.message}")
|
||||||
|
else:
|
||||||
|
logger.error(f"Handled DashboardException: {exc.__class__.__name__} - {exc.message}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={
|
||||||
|
"detail": exc.message,
|
||||||
|
"code": exc.code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
def internal_server_error_handler(request, exception):
|
def internal_server_error_handler(request, exception):
|
||||||
# 에러 메시지 로깅 (한글 포함 가능)
|
# 에러 메시지 로깅 (한글 포함 가능)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""
|
||||||
|
Dashboard Module
|
||||||
|
|
||||||
|
YouTube Analytics API를 활용한 대시보드 기능을 제공합니다.
|
||||||
|
"""
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
Dashboard API Module
|
||||||
|
"""
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
Dashboard Routers
|
||||||
|
"""
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""
|
||||||
|
Dashboard V1 Routers
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.dashboard.api.routers.v1.dashboard import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
"""
|
||||||
|
Dashboard API 라우터
|
||||||
|
|
||||||
|
YouTube Analytics 기반 대시보드 통계를 제공합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dashboard.utils.redis_cache import delete_cache_pattern
|
||||||
|
from app.dashboard.schemas import (
|
||||||
|
CacheDeleteResponse,
|
||||||
|
ConnectedAccountsResponse,
|
||||||
|
DashboardResponse,
|
||||||
|
)
|
||||||
|
from app.dashboard.services import DashboardService
|
||||||
|
from app.database.session import get_session
|
||||||
|
from app.user.dependencies.auth import get_current_user
|
||||||
|
from app.user.models import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/accounts",
|
||||||
|
response_model=ConnectedAccountsResponse,
|
||||||
|
summary="연결된 소셜 계정 목록 조회",
|
||||||
|
description="""
|
||||||
|
연결된 소셜 계정 목록을 반환합니다.
|
||||||
|
|
||||||
|
여러 계정이 연결된 경우, 반환된 `platformUserId` 값을 `/dashboard/stats?platform_user_id=<값>`에 전달하여 계정을 선택합니다.
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
async def get_connected_accounts(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ConnectedAccountsResponse:
|
||||||
|
service = DashboardService()
|
||||||
|
connected = await service.get_connected_accounts(current_user, session)
|
||||||
|
return ConnectedAccountsResponse(accounts=connected)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/stats",
|
||||||
|
response_model=DashboardResponse,
|
||||||
|
summary="대시보드 통계 조회",
|
||||||
|
description="""
|
||||||
|
YouTube Analytics API를 활용한 대시보드 통계를 조회합니다.
|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
- 최근 30개 업로드 영상 기준 통계 제공
|
||||||
|
- KPI 지표: 조회수, 시청시간, 평균 시청시간, 신규 구독자, 좋아요, 댓글, 공유, 업로드 영상
|
||||||
|
- 월별 추이: 최근 12개월 vs 이전 12개월 비교
|
||||||
|
- 인기 영상 TOP 4
|
||||||
|
- 시청자 분석: 연령/성별/지역 분포
|
||||||
|
|
||||||
|
## 성능 최적화
|
||||||
|
- 7개 YouTube Analytics API를 병렬로 호출
|
||||||
|
- Redis 캐싱 적용 (TTL: 12시간)
|
||||||
|
|
||||||
|
## 사전 조건
|
||||||
|
- YouTube 계정이 연동되어 있어야 합니다
|
||||||
|
|
||||||
|
## 조회 모드
|
||||||
|
- `day`: 최근 30일 통계 (현재 날짜 -2일 기준)
|
||||||
|
- `month`: 최근 12개월 통계 (현재 날짜 -2일 기준, 기본값)
|
||||||
|
|
||||||
|
## 데이터 특성
|
||||||
|
- **지연 시간**: 48시간 (2일) - 2월 14일 요청 시 2월 12일 자까지 확정
|
||||||
|
- **업데이트 주기**: 하루 1회 (PT 자정, 한국 시간 오후 5~8시)
|
||||||
|
- **실시간 아님**: 전날 데이터가 다음날 확정됩니다
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
async def get_dashboard_stats(
|
||||||
|
mode: Literal["day", "month"] = Query(
|
||||||
|
default="month",
|
||||||
|
description="조회 모드: day(최근 30일), month(최근 12개월)",
|
||||||
|
),
|
||||||
|
platform_user_id: str | None = Query(
|
||||||
|
default=None,
|
||||||
|
description="사용할 YouTube 채널 ID (platform_user_id)",
|
||||||
|
),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> DashboardResponse:
|
||||||
|
service = DashboardService()
|
||||||
|
return await service.get_stats(mode, platform_user_id, current_user, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/cache",
|
||||||
|
response_model=CacheDeleteResponse,
|
||||||
|
summary="대시보드 캐시 삭제",
|
||||||
|
description="""
|
||||||
|
대시보드 Redis 캐시를 삭제합니다. 인증 없이 호출 가능합니다.
|
||||||
|
|
||||||
|
삭제 후 다음 `/stats` 요청 시 YouTube Analytics API를 새로 호출하여 최신 데이터를 반환합니다.
|
||||||
|
|
||||||
|
## 사용 시나리오
|
||||||
|
- 코드 배포 후 즉시 최신 데이터 반영이 필요할 때
|
||||||
|
- 데이터 이상 발생 시 캐시 강제 갱신
|
||||||
|
|
||||||
|
## 캐시 키 구조
|
||||||
|
`dashboard:{user_uuid}:{platform_user_id}:{mode}` (mode: day 또는 month)
|
||||||
|
|
||||||
|
## 파라미터
|
||||||
|
- `user_uuid`: 삭제할 사용자 UUID (필수)
|
||||||
|
- `mode`: day / month / all (기본값: all)
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
async def delete_dashboard_cache(
|
||||||
|
mode: Literal["day", "month", "all"] = Query(
|
||||||
|
default="all",
|
||||||
|
description="삭제할 캐시 모드: day, month, all(기본값, 모두 삭제)",
|
||||||
|
),
|
||||||
|
user_uuid: str = Query(
|
||||||
|
description="대상 사용자 UUID",
|
||||||
|
),
|
||||||
|
) -> CacheDeleteResponse:
|
||||||
|
if mode == "all":
|
||||||
|
deleted = await delete_cache_pattern(f"dashboard:{user_uuid}:*")
|
||||||
|
message = f"전체 캐시 삭제 완료 ({deleted}개)"
|
||||||
|
else:
|
||||||
|
deleted = await delete_cache_pattern(f"dashboard:{user_uuid}:*:{mode}")
|
||||||
|
message = f"{mode} 캐시 삭제 완료 ({deleted}개)"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[CACHE DELETE] user_uuid={user_uuid or 'ALL'}, mode={mode}, deleted={deleted}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return CacheDeleteResponse(deleted_count=deleted, message=message)
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
"""
|
||||||
|
Dashboard Exceptions
|
||||||
|
|
||||||
|
Dashboard API 관련 예외 클래스를 정의합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import status
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardException(Exception):
|
||||||
|
"""Dashboard 기본 예외"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
code: str = "DASHBOARD_ERROR",
|
||||||
|
):
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
self.code = code
|
||||||
|
super().__init__(self.message)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# YouTube Analytics API 관련 예외
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class YouTubeAPIException(DashboardException):
|
||||||
|
"""YouTube Analytics API 관련 예외 기본 클래스"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str = "YouTube Analytics API 호출 중 오류가 발생했습니다.",
|
||||||
|
status_code: int = status.HTTP_502_BAD_GATEWAY,
|
||||||
|
code: str = "YOUTUBE_API_ERROR",
|
||||||
|
):
|
||||||
|
super().__init__(message, status_code, code)
|
||||||
|
|
||||||
|
|
||||||
|
class YouTubeAPIError(YouTubeAPIException):
|
||||||
|
"""YouTube Analytics API 일반 오류"""
|
||||||
|
|
||||||
|
def __init__(self, detail: str = ""):
|
||||||
|
error_message = "YouTube Analytics API 호출에 실패했습니다."
|
||||||
|
if detail:
|
||||||
|
error_message += f" ({detail})"
|
||||||
|
super().__init__(
|
||||||
|
message=error_message,
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
code="YOUTUBE_API_FAILED",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class YouTubeAuthError(YouTubeAPIException):
|
||||||
|
"""YouTube 인증 실패"""
|
||||||
|
|
||||||
|
def __init__(self, detail: str = ""):
|
||||||
|
error_message = "YouTube 인증에 실패했습니다. 계정 재연동이 필요합니다."
|
||||||
|
if detail:
|
||||||
|
error_message += f" ({detail})"
|
||||||
|
super().__init__(
|
||||||
|
message=error_message,
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
code="YOUTUBE_AUTH_FAILED",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class YouTubeQuotaExceededError(YouTubeAPIException):
|
||||||
|
"""YouTube API 할당량 초과"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
message="YouTube API 일일 할당량이 초과되었습니다. 내일 다시 시도해주세요.",
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
code="YOUTUBE_QUOTA_EXCEEDED",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class YouTubeDataNotFoundError(YouTubeAPIException):
|
||||||
|
"""YouTube Analytics 데이터 없음"""
|
||||||
|
|
||||||
|
def __init__(self, detail: str = ""):
|
||||||
|
error_message = "YouTube Analytics 데이터를 찾을 수 없습니다."
|
||||||
|
if detail:
|
||||||
|
error_message += f" ({detail})"
|
||||||
|
super().__init__(
|
||||||
|
message=error_message,
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
code="YOUTUBE_DATA_NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 계정 연동 관련 예외
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class YouTubeAccountNotConnectedError(DashboardException):
|
||||||
|
"""YouTube 계정 미연동"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
message="YouTube 계정이 연동되어 있지 않습니다. 먼저 YouTube 계정을 연동해주세요.",
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
code="YOUTUBE_NOT_CONNECTED",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class YouTubeAccountSelectionRequiredError(DashboardException):
|
||||||
|
"""여러 YouTube 계정이 연동된 경우 계정 선택 필요"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
message="연결된 YouTube 계정이 여러 개입니다. platform_user_id 파라미터로 사용할 계정을 선택해주세요.",
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
code="YOUTUBE_ACCOUNT_SELECTION_REQUIRED",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class YouTubeAccountNotFoundError(DashboardException):
|
||||||
|
"""지정한 YouTube 계정을 찾을 수 없음"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
message="지정한 YouTube 계정을 찾을 수 없습니다.",
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
code="YOUTUBE_ACCOUNT_NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class YouTubeTokenExpiredError(DashboardException):
|
||||||
|
"""YouTube 토큰 만료"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
message="YouTube 인증이 만료되었습니다. 계정을 재연동해주세요.",
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
code="YOUTUBE_TOKEN_EXPIRED",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 데이터 관련 예외
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class NoVideosFoundError(DashboardException):
|
||||||
|
"""업로드된 영상 없음"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
message="업로드된 YouTube 영상이 없습니다. 먼저 영상을 업로드해주세요.",
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
code="NO_VIDEOS_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardDataError(DashboardException):
|
||||||
|
"""대시보드 데이터 처리 오류"""
|
||||||
|
|
||||||
|
def __init__(self, detail: str = ""):
|
||||||
|
error_message = "대시보드 데이터 처리 중 오류가 발생했습니다."
|
||||||
|
if detail:
|
||||||
|
error_message += f" ({detail})"
|
||||||
|
super().__init__(
|
||||||
|
message=error_message,
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
code="DASHBOARD_DATA_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 캐싱 관련 예외 (경고용, 실제로는 raise하지 않고 로깅만 사용)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class CacheError(DashboardException):
|
||||||
|
"""캐시 작업 오류
|
||||||
|
|
||||||
|
Note:
|
||||||
|
이 예외는 실제로 raise되지 않고,
|
||||||
|
캐시 실패 시 로깅만 하고 원본 데이터를 반환합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, operation: str, detail: str = ""):
|
||||||
|
error_message = f"캐시 {operation} 작업 중 오류가 발생했습니다."
|
||||||
|
if detail:
|
||||||
|
error_message += f" ({detail})"
|
||||||
|
super().__init__(
|
||||||
|
message=error_message,
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
code="CACHE_ERROR",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
"""
|
||||||
|
Dashboard Migration
|
||||||
|
|
||||||
|
dashboard 테이블 초기화 및 기존 데이터 마이그레이션을 담당합니다.
|
||||||
|
서버 기동 시 create_db_tables() 이후 호출됩니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import func, select, text
|
||||||
|
from sqlalchemy.dialects.mysql import insert
|
||||||
|
|
||||||
|
from app.dashboard.models import Dashboard
|
||||||
|
from app.database.session import AsyncSessionLocal, engine
|
||||||
|
from app.social.models import SocialUpload
|
||||||
|
from app.user.models import SocialAccount
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _dashboard_table_exists() -> bool:
|
||||||
|
"""dashboard 테이블 존재 여부 확인"""
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
result = await conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.tables "
|
||||||
|
"WHERE table_schema = DATABASE() AND table_name = 'dashboard'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar() > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def _dashboard_is_empty() -> bool:
|
||||||
|
"""dashboard 테이블 데이터 존재 여부 확인"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(func.count()).select_from(Dashboard)
|
||||||
|
)
|
||||||
|
return result.scalar() == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def _migrate_existing_data() -> None:
|
||||||
|
"""
|
||||||
|
SocialUpload(status=completed) → Dashboard 마이그레이션.
|
||||||
|
INSERT IGNORE로 중복 안전하게 삽입.
|
||||||
|
"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(
|
||||||
|
SocialUpload.user_uuid,
|
||||||
|
SocialUpload.platform,
|
||||||
|
SocialUpload.platform_video_id,
|
||||||
|
SocialUpload.platform_url,
|
||||||
|
SocialUpload.title,
|
||||||
|
SocialUpload.uploaded_at,
|
||||||
|
SocialAccount.platform_user_id,
|
||||||
|
)
|
||||||
|
.join(SocialAccount, SocialUpload.social_account_id == SocialAccount.id)
|
||||||
|
.where(
|
||||||
|
SocialUpload.status == "completed",
|
||||||
|
SocialUpload.platform_video_id.isnot(None),
|
||||||
|
SocialUpload.uploaded_at.isnot(None),
|
||||||
|
SocialAccount.platform_user_id.isnot(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
logger.info("[DASHBOARD_MIGRATE] 마이그레이션 대상 없음")
|
||||||
|
return
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
for row in rows:
|
||||||
|
stmt = (
|
||||||
|
insert(Dashboard)
|
||||||
|
.values(
|
||||||
|
user_uuid=row.user_uuid,
|
||||||
|
platform=row.platform,
|
||||||
|
platform_user_id=row.platform_user_id,
|
||||||
|
platform_video_id=row.platform_video_id,
|
||||||
|
platform_url=row.platform_url,
|
||||||
|
title=row.title,
|
||||||
|
uploaded_at=row.uploaded_at,
|
||||||
|
)
|
||||||
|
.prefix_with("IGNORE")
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(f"[DASHBOARD_MIGRATE] 마이그레이션 완료 - {len(rows)}건 삽입")
|
||||||
|
|
||||||
|
|
||||||
|
async def init_dashboard_table() -> None:
|
||||||
|
"""
|
||||||
|
dashboard 테이블 초기화 진입점.
|
||||||
|
|
||||||
|
- 테이블이 없으면 생성 후 마이그레이션
|
||||||
|
- 테이블이 있지만 비어있으면 마이그레이션 (DEBUG 모드에서 create_db_tables()가 빈 테이블 생성한 경우)
|
||||||
|
- 테이블이 있고 데이터도 있으면 스킵
|
||||||
|
"""
|
||||||
|
if not await _dashboard_table_exists():
|
||||||
|
logger.info("[DASHBOARD_MIGRATE] dashboard 테이블 없음 - 생성 및 마이그레이션 시작")
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(
|
||||||
|
lambda c: Dashboard.__table__.create(c, checkfirst=True)
|
||||||
|
)
|
||||||
|
await _migrate_existing_data()
|
||||||
|
elif await _dashboard_is_empty():
|
||||||
|
logger.info("[DASHBOARD_MIGRATE] dashboard 테이블 비어있음 - 마이그레이션 시작")
|
||||||
|
await _migrate_existing_data()
|
||||||
|
else:
|
||||||
|
logger.info("[DASHBOARD_MIGRATE] dashboard 테이블 이미 존재 - 스킵")
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
"""
|
||||||
|
Dashboard Models
|
||||||
|
|
||||||
|
대시보드 전용 SQLAlchemy 모델을 정의합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, DateTime, Index, String, UniqueConstraint, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.database.session import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Dashboard(Base):
|
||||||
|
"""
|
||||||
|
채널별 영상 업로드 기록 테이블
|
||||||
|
|
||||||
|
YouTube 업로드 완료 시 채널 ID(platform_user_id)와 함께 기록합니다.
|
||||||
|
SocialUpload.social_account_id는 재연동 시 변경되므로,
|
||||||
|
이 테이블로 채널 기준 안정적인 영상 필터링을 제공합니다.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: 고유 식별자 (자동 증가)
|
||||||
|
user_uuid: 사용자 UUID (User.user_uuid 참조)
|
||||||
|
platform: 플랫폼 (youtube/instagram)
|
||||||
|
platform_user_id: 채널 ID (재연동 후에도 불변)
|
||||||
|
platform_video_id: 영상 ID
|
||||||
|
platform_url: 영상 URL
|
||||||
|
title: 영상 제목
|
||||||
|
uploaded_at: SocialUpload 완료 시각
|
||||||
|
created_at: 레코드 생성 시각
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "dashboard"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"platform_video_id",
|
||||||
|
"platform_user_id",
|
||||||
|
name="uq_vcu_video_channel",
|
||||||
|
),
|
||||||
|
Index("idx_vcu_user_platform", "user_uuid", "platform_user_id"),
|
||||||
|
Index("idx_vcu_uploaded_at", "uploaded_at"),
|
||||||
|
{
|
||||||
|
"mysql_engine": "InnoDB",
|
||||||
|
"mysql_charset": "utf8mb4",
|
||||||
|
"mysql_collate": "utf8mb4_unicode_ci",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 기본 식별자
|
||||||
|
# ==========================================================================
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger,
|
||||||
|
primary_key=True,
|
||||||
|
nullable=False,
|
||||||
|
autoincrement=True,
|
||||||
|
comment="고유 식별자",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 관계 필드
|
||||||
|
# ==========================================================================
|
||||||
|
user_uuid: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
nullable=False,
|
||||||
|
comment="사용자 UUID (User.user_uuid 참조)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 플랫폼 정보
|
||||||
|
# ==========================================================================
|
||||||
|
platform: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
nullable=False,
|
||||||
|
comment="플랫폼 (youtube/instagram)",
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_user_id: Mapped[str] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=False,
|
||||||
|
comment="채널 ID (재연동 후에도 불변)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 플랫폼 결과
|
||||||
|
# ==========================================================================
|
||||||
|
platform_video_id: Mapped[str] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=False,
|
||||||
|
comment="영상 ID",
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_url: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(500),
|
||||||
|
nullable=True,
|
||||||
|
comment="영상 URL",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 메타데이터
|
||||||
|
# ==========================================================================
|
||||||
|
title: Mapped[str] = mapped_column(
|
||||||
|
String(200),
|
||||||
|
nullable=False,
|
||||||
|
comment="영상 제목",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 시간 정보
|
||||||
|
# ==========================================================================
|
||||||
|
uploaded_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
nullable=False,
|
||||||
|
comment="SocialUpload 완료 시각",
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
comment="레코드 생성 시각",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"<Dashboard("
|
||||||
|
f"id={self.id}, "
|
||||||
|
f"platform_user_id='{self.platform_user_id}', "
|
||||||
|
f"platform_video_id='{self.platform_video_id}'"
|
||||||
|
f")>"
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""
|
||||||
|
Dashboard Schemas
|
||||||
|
|
||||||
|
Dashboard API의 요청/응답 스키마를 정의합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.dashboard.schemas.dashboard_schema import (
|
||||||
|
AudienceData,
|
||||||
|
CacheDeleteResponse,
|
||||||
|
ConnectedAccount,
|
||||||
|
ConnectedAccountsResponse,
|
||||||
|
ContentMetric,
|
||||||
|
DailyData,
|
||||||
|
DashboardResponse,
|
||||||
|
MonthlyData,
|
||||||
|
TopContent,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ConnectedAccount",
|
||||||
|
"ConnectedAccountsResponse",
|
||||||
|
"ContentMetric",
|
||||||
|
"DailyData",
|
||||||
|
"MonthlyData",
|
||||||
|
"TopContent",
|
||||||
|
"AudienceData",
|
||||||
|
"DashboardResponse",
|
||||||
|
"CacheDeleteResponse",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
"""
|
||||||
|
Dashboard API Schemas
|
||||||
|
|
||||||
|
대시보드 API의 요청/응답 Pydantic 스키마를 정의합니다.
|
||||||
|
YouTube Analytics API 데이터를 프론트엔드에 전달하기 위한 모델입니다.
|
||||||
|
|
||||||
|
사용 예시:
|
||||||
|
from app.dashboard.schemas import DashboardResponse, ContentMetric
|
||||||
|
|
||||||
|
# 라우터에서 response_model로 사용
|
||||||
|
@router.get("/dashboard/stats", response_model=DashboardResponse)
|
||||||
|
async def get_dashboard_stats():
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal, Optional
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
def to_camel(string: str) -> str:
|
||||||
|
"""snake_case를 camelCase로 변환
|
||||||
|
|
||||||
|
Args:
|
||||||
|
string: snake_case 문자열 (예: "content_metrics")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
camelCase 문자열 (예: "contentMetrics")
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> to_camel("content_metrics")
|
||||||
|
"contentMetrics"
|
||||||
|
>>> to_camel("this_year")
|
||||||
|
"thisYear"
|
||||||
|
"""
|
||||||
|
components = string.split("_")
|
||||||
|
return components[0] + "".join(x.capitalize() for x in components[1:])
|
||||||
|
|
||||||
|
|
||||||
|
class ContentMetric(BaseModel):
|
||||||
|
"""KPI 지표 카드
|
||||||
|
|
||||||
|
대시보드 상단에 표시되는 핵심 성과 지표(KPI) 카드입니다.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: 지표 고유 ID (예: "total-views", "total-watch-time", "new-subscribers")
|
||||||
|
label: 한글 라벨 (예: "조회수")
|
||||||
|
value: 원시 숫자값 (단위: unit 참조, 포맷팅은 프론트에서 처리)
|
||||||
|
unit: 값의 단위 — "count" | "hours" | "minutes"
|
||||||
|
- count: 조회수, 구독자, 좋아요, 댓글, 공유, 업로드 영상
|
||||||
|
- hours: 시청시간 (estimatedMinutesWatched / 60)
|
||||||
|
- minutes: 평균 시청시간 (averageViewDuration / 60)
|
||||||
|
trend: 이전 기간 대비 증감량 (unit과 동일한 단위)
|
||||||
|
trend_direction: 증감 방향 ("up": 증가, "down": 감소, "-": 변동 없음)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> metric = ContentMetric(
|
||||||
|
... id="total-views",
|
||||||
|
... label="조회수",
|
||||||
|
... value=1200000.0,
|
||||||
|
... unit="count",
|
||||||
|
... trend=3800.0,
|
||||||
|
... trend_direction="up"
|
||||||
|
... )
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
value: float
|
||||||
|
unit: str = "count"
|
||||||
|
trend: float
|
||||||
|
trend_direction: Literal["up", "down", "-"] = Field(alias="trendDirection")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
alias_generator=to_camel,
|
||||||
|
populate_by_name=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MonthlyData(BaseModel):
|
||||||
|
"""월별 추이 데이터
|
||||||
|
|
||||||
|
전년 대비 월별 조회수 비교 데이터입니다.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
month: 월 표시 (예: "1월", "2월")
|
||||||
|
this_year: 올해 해당 월 조회수
|
||||||
|
last_year: 작년 해당 월 조회수
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> data = MonthlyData(
|
||||||
|
... month="1월",
|
||||||
|
... this_year=150000,
|
||||||
|
... last_year=120000
|
||||||
|
... )
|
||||||
|
"""
|
||||||
|
|
||||||
|
month: str
|
||||||
|
this_year: int = Field(alias="thisYear")
|
||||||
|
last_year: int = Field(alias="lastYear")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
alias_generator=to_camel,
|
||||||
|
populate_by_name=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DailyData(BaseModel):
|
||||||
|
"""일별 추이 데이터 (mode=day 전용)
|
||||||
|
|
||||||
|
최근 30일과 이전 30일의 일별 조회수 비교 데이터입니다.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
date: 날짜 표시 (예: "1/18", "1/19")
|
||||||
|
this_period: 최근 30일 조회수
|
||||||
|
last_period: 이전 30일 동일 요일 조회수
|
||||||
|
"""
|
||||||
|
|
||||||
|
date: str
|
||||||
|
this_period: int = Field(alias="thisPeriod")
|
||||||
|
last_period: int = Field(alias="lastPeriod")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
alias_generator=to_camel,
|
||||||
|
populate_by_name=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TopContent(BaseModel):
|
||||||
|
"""인기 영상
|
||||||
|
|
||||||
|
조회수 기준 상위 인기 영상 정보입니다.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: YouTube 영상 ID
|
||||||
|
title: 영상 제목
|
||||||
|
thumbnail: 썸네일 이미지 URL
|
||||||
|
platform: 플랫폼 ("youtube" 또는 "instagram")
|
||||||
|
views: 원시 조회수 정수 (포맷팅은 프론트에서 처리, 예: 125400)
|
||||||
|
engagement: 참여율 (예: "8.2%")
|
||||||
|
date: 업로드 날짜 (예: "2026.01.15")
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> content = TopContent(
|
||||||
|
... id="video-id-1",
|
||||||
|
... title="힐링 영상",
|
||||||
|
... thumbnail="https://i.ytimg.com/...",
|
||||||
|
... platform="youtube",
|
||||||
|
... views=125400,
|
||||||
|
... engagement="8.2%",
|
||||||
|
... date="2026.01.15"
|
||||||
|
... )
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
thumbnail: str
|
||||||
|
platform: Literal["youtube", "instagram"]
|
||||||
|
views: int
|
||||||
|
engagement: str
|
||||||
|
date: str
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
alias_generator=to_camel,
|
||||||
|
populate_by_name=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AudienceData(BaseModel):
|
||||||
|
"""시청자 분석 데이터
|
||||||
|
|
||||||
|
시청자의 연령대, 성별, 지역 분포 데이터입니다.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
age_groups: 연령대별 시청자 비율 리스트
|
||||||
|
[{"label": "18-24", "percentage": 35}, ...]
|
||||||
|
gender: 성별 시청자 비율 (YouTube viewerPercentage 누적값)
|
||||||
|
{"male": 45, "female": 55}
|
||||||
|
top_regions: 상위 국가 리스트 (최대 5개)
|
||||||
|
[{"region": "대한민국", "percentage": 42}, ...]
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> data = AudienceData(
|
||||||
|
... age_groups=[{"label": "18-24", "percentage": 35}],
|
||||||
|
... gender={"male": 45, "female": 55},
|
||||||
|
... top_regions=[{"region": "대한민국", "percentage": 42}]
|
||||||
|
... )
|
||||||
|
"""
|
||||||
|
|
||||||
|
age_groups: list[dict[str, Any]] = Field(alias="ageGroups")
|
||||||
|
gender: dict[str, int]
|
||||||
|
top_regions: list[dict[str, Any]] = Field(alias="topRegions")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
alias_generator=to_camel,
|
||||||
|
populate_by_name=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardResponse(BaseModel):
|
||||||
|
"""대시보드 전체 응답
|
||||||
|
|
||||||
|
GET /dashboard/stats 엔드포인트의 전체 응답 스키마입니다.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
content_metrics: KPI 지표 카드 리스트 (8개)
|
||||||
|
monthly_data: 월별 추이 데이터 (mode=month 시 채움, 최근 12개월 vs 이전 12개월)
|
||||||
|
daily_data: 일별 추이 데이터 (mode=day 시 채움, 최근 30일 vs 이전 30일)
|
||||||
|
top_content: 조회수 기준 인기 영상 TOP 4
|
||||||
|
audience_data: 시청자 분석 데이터 (연령/성별/지역)
|
||||||
|
has_uploads: 업로드 영상 존재 여부 (False 시 모든 지표가 0, 빈 상태 UI 표시용)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> response = DashboardResponse(
|
||||||
|
... content_metrics=[...],
|
||||||
|
... monthly_data=[...],
|
||||||
|
... top_content=[...],
|
||||||
|
... audience_data=AudienceData(...),
|
||||||
|
... )
|
||||||
|
>>> json_str = response.model_dump_json() # JSON 직렬화
|
||||||
|
"""
|
||||||
|
|
||||||
|
content_metrics: list[ContentMetric] = Field(alias="contentMetrics")
|
||||||
|
monthly_data: list[MonthlyData] = Field(default=[], alias="monthlyData")
|
||||||
|
daily_data: list[DailyData] = Field(default=[], alias="dailyData")
|
||||||
|
top_content: list[TopContent] = Field(alias="topContent")
|
||||||
|
audience_data: AudienceData = Field(alias="audienceData")
|
||||||
|
has_uploads: bool = Field(default=True, alias="hasUploads")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
alias_generator=to_camel,
|
||||||
|
populate_by_name=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectedAccount(BaseModel):
|
||||||
|
"""연결된 소셜 계정 정보
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: SocialAccount 테이블 PK
|
||||||
|
platform: 플랫폼 (예: "youtube")
|
||||||
|
platform_username: 플랫폼 사용자명 (예: "@channelname")
|
||||||
|
platform_user_id: 플랫폼 채널 고유 ID — 재연동해도 불변.
|
||||||
|
/dashboard/stats?platform_user_id=<값> 으로 계정 선택에 사용
|
||||||
|
channel_title: YouTube 채널 제목 (SocialAccount.platform_data JSON에서 추출)
|
||||||
|
connected_at: 연동 일시
|
||||||
|
is_active: 활성화 상태
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
platform: str
|
||||||
|
platform_user_id: str
|
||||||
|
platform_username: Optional[str] = None
|
||||||
|
channel_title: Optional[str] = None
|
||||||
|
connected_at: datetime
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
alias_generator=to_camel,
|
||||||
|
populate_by_name=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectedAccountsResponse(BaseModel):
|
||||||
|
"""연결된 소셜 계정 목록 응답
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
accounts: 연결된 계정 목록
|
||||||
|
"""
|
||||||
|
|
||||||
|
accounts: list[ConnectedAccount]
|
||||||
|
|
||||||
|
|
||||||
|
class CacheDeleteResponse(BaseModel):
|
||||||
|
"""캐시 삭제 응답
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
deleted_count: 삭제된 캐시 키 개수
|
||||||
|
message: 처리 결과 메시지
|
||||||
|
"""
|
||||||
|
|
||||||
|
deleted_count: int
|
||||||
|
message: str
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
"""
|
||||||
|
Dashboard Services
|
||||||
|
|
||||||
|
YouTube Analytics API 연동 및 데이터 가공 서비스를 제공합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.dashboard.services.dashboard_service import DashboardService
|
||||||
|
from app.dashboard.services.data_processor import DataProcessor
|
||||||
|
from app.dashboard.services.youtube_analytics import YouTubeAnalyticsService
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DashboardService",
|
||||||
|
"YouTubeAnalyticsService",
|
||||||
|
"DataProcessor",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,358 @@
|
||||||
|
"""
|
||||||
|
Dashboard Service
|
||||||
|
|
||||||
|
대시보드 비즈니스 로직을 담당합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dashboard.exceptions import (
|
||||||
|
YouTubeAccountNotConnectedError,
|
||||||
|
YouTubeAccountNotFoundError,
|
||||||
|
YouTubeAccountSelectionRequiredError,
|
||||||
|
YouTubeTokenExpiredError,
|
||||||
|
)
|
||||||
|
from app.dashboard.models import Dashboard
|
||||||
|
from app.dashboard.utils.redis_cache import get_cache, set_cache
|
||||||
|
from app.dashboard.schemas import (
|
||||||
|
AudienceData,
|
||||||
|
ConnectedAccount,
|
||||||
|
ContentMetric,
|
||||||
|
DashboardResponse,
|
||||||
|
TopContent,
|
||||||
|
)
|
||||||
|
from app.dashboard.services.data_processor import DataProcessor
|
||||||
|
from app.dashboard.services.youtube_analytics import YouTubeAnalyticsService
|
||||||
|
from app.social.exceptions import TokenExpiredError
|
||||||
|
from app.social.services import SocialAccountService
|
||||||
|
from app.user.models import SocialAccount, User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardService:
|
||||||
|
async def get_connected_accounts(
|
||||||
|
self,
|
||||||
|
current_user: User,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> list[ConnectedAccount]:
|
||||||
|
result = await session.execute(
|
||||||
|
select(SocialAccount).where(
|
||||||
|
SocialAccount.user_uuid == current_user.user_uuid,
|
||||||
|
SocialAccount.platform == "youtube",
|
||||||
|
SocialAccount.is_active == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
accounts_raw = result.scalars().all()
|
||||||
|
|
||||||
|
connected = []
|
||||||
|
for acc in accounts_raw:
|
||||||
|
data = acc.platform_data if isinstance(acc.platform_data, dict) else {}
|
||||||
|
connected.append(
|
||||||
|
ConnectedAccount(
|
||||||
|
id=acc.id,
|
||||||
|
platform=acc.platform,
|
||||||
|
platform_username=acc.platform_username,
|
||||||
|
platform_user_id=acc.platform_user_id,
|
||||||
|
channel_title=data.get("channel_title"),
|
||||||
|
connected_at=acc.connected_at,
|
||||||
|
is_active=acc.is_active,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[ACCOUNTS] YouTube 계정 목록 조회 - "
|
||||||
|
f"user_uuid={current_user.user_uuid}, count={len(connected)}"
|
||||||
|
)
|
||||||
|
return connected
|
||||||
|
|
||||||
|
def calculate_date_range(
|
||||||
|
self, mode: Literal["day", "month"]
|
||||||
|
) -> tuple[date, date, date, date, date, str]:
|
||||||
|
"""모드별 날짜 범위 계산. (start_dt, end_dt, kpi_end_dt, prev_start_dt, prev_kpi_end_dt, period_desc) 반환"""
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
if mode == "day":
|
||||||
|
end_dt = today - timedelta(days=2)
|
||||||
|
kpi_end_dt = end_dt
|
||||||
|
start_dt = end_dt - timedelta(days=29)
|
||||||
|
prev_start_dt = start_dt - timedelta(days=30)
|
||||||
|
prev_kpi_end_dt = kpi_end_dt - timedelta(days=30)
|
||||||
|
period_desc = "최근 30일"
|
||||||
|
else:
|
||||||
|
end_dt = today.replace(day=1)
|
||||||
|
kpi_end_dt = today - timedelta(days=2)
|
||||||
|
start_month = end_dt.month - 11
|
||||||
|
if start_month <= 0:
|
||||||
|
start_month += 12
|
||||||
|
start_year = end_dt.year - 1
|
||||||
|
else:
|
||||||
|
start_year = end_dt.year
|
||||||
|
start_dt = date(start_year, start_month, 1)
|
||||||
|
prev_start_dt = start_dt.replace(year=start_dt.year - 1)
|
||||||
|
try:
|
||||||
|
prev_kpi_end_dt = kpi_end_dt.replace(year=kpi_end_dt.year - 1)
|
||||||
|
except ValueError:
|
||||||
|
prev_kpi_end_dt = kpi_end_dt.replace(year=kpi_end_dt.year - 1, day=28)
|
||||||
|
period_desc = "최근 12개월"
|
||||||
|
|
||||||
|
return start_dt, end_dt, kpi_end_dt, prev_start_dt, prev_kpi_end_dt, period_desc
|
||||||
|
|
||||||
|
async def resolve_social_account(
|
||||||
|
self,
|
||||||
|
current_user: User,
|
||||||
|
session: AsyncSession,
|
||||||
|
platform_user_id: str | None,
|
||||||
|
) -> SocialAccount:
|
||||||
|
result = await session.execute(
|
||||||
|
select(SocialAccount).where(
|
||||||
|
SocialAccount.user_uuid == current_user.user_uuid,
|
||||||
|
SocialAccount.platform == "youtube",
|
||||||
|
SocialAccount.is_active == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
social_accounts_raw = result.scalars().all()
|
||||||
|
|
||||||
|
social_accounts = list(social_accounts_raw)
|
||||||
|
|
||||||
|
if not social_accounts:
|
||||||
|
raise YouTubeAccountNotConnectedError()
|
||||||
|
|
||||||
|
if platform_user_id is not None:
|
||||||
|
matched = [a for a in social_accounts if a.platform_user_id == platform_user_id]
|
||||||
|
if not matched:
|
||||||
|
raise YouTubeAccountNotFoundError()
|
||||||
|
return matched[0]
|
||||||
|
elif len(social_accounts) == 1:
|
||||||
|
return social_accounts[0]
|
||||||
|
else:
|
||||||
|
raise YouTubeAccountSelectionRequiredError()
|
||||||
|
|
||||||
|
async def get_video_counts(
|
||||||
|
self,
|
||||||
|
current_user: User,
|
||||||
|
session: AsyncSession,
|
||||||
|
social_account: SocialAccount,
|
||||||
|
start_dt: date,
|
||||||
|
prev_start_dt: date,
|
||||||
|
prev_kpi_end_dt: date,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
today = date.today()
|
||||||
|
count_result = await session.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Dashboard)
|
||||||
|
.where(
|
||||||
|
Dashboard.user_uuid == current_user.user_uuid,
|
||||||
|
Dashboard.platform == "youtube",
|
||||||
|
Dashboard.platform_user_id == social_account.platform_user_id,
|
||||||
|
Dashboard.uploaded_at >= start_dt,
|
||||||
|
Dashboard.uploaded_at < today + timedelta(days=1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
period_video_count = count_result.scalar() or 0
|
||||||
|
|
||||||
|
prev_count_result = await session.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Dashboard)
|
||||||
|
.where(
|
||||||
|
Dashboard.user_uuid == current_user.user_uuid,
|
||||||
|
Dashboard.platform == "youtube",
|
||||||
|
Dashboard.platform_user_id == social_account.platform_user_id,
|
||||||
|
Dashboard.uploaded_at >= prev_start_dt,
|
||||||
|
Dashboard.uploaded_at <= prev_kpi_end_dt,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
prev_period_video_count = prev_count_result.scalar() or 0
|
||||||
|
|
||||||
|
return period_video_count, prev_period_video_count
|
||||||
|
|
||||||
|
async def get_video_ids(
|
||||||
|
self,
|
||||||
|
current_user: User,
|
||||||
|
session: AsyncSession,
|
||||||
|
social_account: SocialAccount,
|
||||||
|
) -> tuple[list[str], dict[str, tuple[str, datetime]]]:
|
||||||
|
result = await session.execute(
|
||||||
|
select(
|
||||||
|
Dashboard.platform_video_id,
|
||||||
|
Dashboard.title,
|
||||||
|
Dashboard.uploaded_at,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
Dashboard.user_uuid == current_user.user_uuid,
|
||||||
|
Dashboard.platform == "youtube",
|
||||||
|
Dashboard.platform_user_id == social_account.platform_user_id,
|
||||||
|
)
|
||||||
|
.order_by(Dashboard.uploaded_at.desc())
|
||||||
|
.limit(30)
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
video_ids = []
|
||||||
|
video_lookup: dict[str, tuple[str, datetime]] = {}
|
||||||
|
for row in rows:
|
||||||
|
platform_video_id, title, uploaded_at = row
|
||||||
|
video_ids.append(platform_video_id)
|
||||||
|
video_lookup[platform_video_id] = (title, uploaded_at)
|
||||||
|
|
||||||
|
return video_ids, video_lookup
|
||||||
|
|
||||||
|
def build_empty_response(self) -> DashboardResponse:
|
||||||
|
return DashboardResponse(
|
||||||
|
content_metrics=[
|
||||||
|
ContentMetric(id="total-views", label="조회수", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
||||||
|
ContentMetric(id="total-watch-time", label="시청시간", value=0.0, unit="hours", trend=0.0, trend_direction="-"),
|
||||||
|
ContentMetric(id="avg-view-duration", label="평균 시청시간", value=0.0, unit="minutes", trend=0.0, trend_direction="-"),
|
||||||
|
ContentMetric(id="new-subscribers", label="신규 구독자", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
||||||
|
ContentMetric(id="likes", label="좋아요", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
||||||
|
ContentMetric(id="comments", label="댓글", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
||||||
|
ContentMetric(id="shares", label="공유", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
||||||
|
ContentMetric(id="uploaded-videos", label="업로드 영상", value=0.0, unit="count", trend=0.0, trend_direction="-"),
|
||||||
|
],
|
||||||
|
monthly_data=[],
|
||||||
|
daily_data=[],
|
||||||
|
top_content=[],
|
||||||
|
audience_data=AudienceData(age_groups=[], gender={"male": 0, "female": 0}, top_regions=[]),
|
||||||
|
has_uploads=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def inject_video_count(
|
||||||
|
self,
|
||||||
|
response: DashboardResponse,
|
||||||
|
period_video_count: int,
|
||||||
|
prev_period_video_count: int,
|
||||||
|
) -> None:
|
||||||
|
for metric in response.content_metrics:
|
||||||
|
if metric.id == "uploaded-videos":
|
||||||
|
metric.value = float(period_video_count)
|
||||||
|
video_trend = float(period_video_count - prev_period_video_count)
|
||||||
|
metric.trend = video_trend
|
||||||
|
metric.trend_direction = "up" if video_trend > 0 else ("down" if video_trend < 0 else "-")
|
||||||
|
break
|
||||||
|
|
||||||
|
async def get_stats(
|
||||||
|
self,
|
||||||
|
mode: Literal["day", "month"],
|
||||||
|
platform_user_id: str | None,
|
||||||
|
current_user: User,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> DashboardResponse:
|
||||||
|
logger.info(
|
||||||
|
f"[DASHBOARD] 통계 조회 시작 - "
|
||||||
|
f"user_uuid={current_user.user_uuid}, mode={mode}, platform_user_id={platform_user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. 날짜 계산
|
||||||
|
start_dt, end_dt, kpi_end_dt, prev_start_dt, prev_kpi_end_dt, period_desc = (
|
||||||
|
self.calculate_date_range(mode)
|
||||||
|
)
|
||||||
|
start_date = start_dt.strftime("%Y-%m-%d")
|
||||||
|
end_date = end_dt.strftime("%Y-%m-%d")
|
||||||
|
kpi_end_date = kpi_end_dt.strftime("%Y-%m-%d")
|
||||||
|
logger.debug(f"[1] 날짜 계산 완료 - period={period_desc}, start={start_date}, end={end_date}")
|
||||||
|
|
||||||
|
# 2. YouTube 계정 확인
|
||||||
|
social_account = await self.resolve_social_account(current_user, session, platform_user_id)
|
||||||
|
logger.debug(f"[2] YouTube 계정 확인 완료 - platform_user_id={social_account.platform_user_id}")
|
||||||
|
|
||||||
|
# 3. 영상 수 조회
|
||||||
|
period_video_count, prev_period_video_count = await self.get_video_counts(
|
||||||
|
current_user, session, social_account, start_dt, prev_start_dt, prev_kpi_end_dt
|
||||||
|
)
|
||||||
|
logger.debug(f"[3] 영상 수 - current={period_video_count}, prev={prev_period_video_count}")
|
||||||
|
|
||||||
|
# 4. 캐시 조회
|
||||||
|
cache_key = f"dashboard:{current_user.user_uuid}:{social_account.platform_user_id}:{mode}"
|
||||||
|
cached_raw = await get_cache(cache_key)
|
||||||
|
if cached_raw:
|
||||||
|
try:
|
||||||
|
payload = json.loads(cached_raw)
|
||||||
|
logger.info(f"[CACHE HIT] 캐시 반환 - user_uuid={current_user.user_uuid}")
|
||||||
|
response = DashboardResponse.model_validate(payload["response"])
|
||||||
|
self.inject_video_count(response, period_video_count, prev_period_video_count)
|
||||||
|
return response
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
logger.warning(f"[CACHE PARSE ERROR] 포맷 오류, 무시 - key={cache_key}")
|
||||||
|
|
||||||
|
logger.debug("[4] 캐시 MISS - YouTube API 호출 필요")
|
||||||
|
|
||||||
|
# 5. 업로드 영상 조회
|
||||||
|
video_ids, video_lookup = await self.get_video_ids(current_user, session, social_account)
|
||||||
|
logger.debug(f"[5] 영상 조회 완료 - count={len(video_ids)}")
|
||||||
|
|
||||||
|
if not video_ids:
|
||||||
|
logger.info(f"[DASHBOARD] 업로드 영상 없음, 빈 응답 반환 - user_uuid={current_user.user_uuid}")
|
||||||
|
return self.build_empty_response()
|
||||||
|
|
||||||
|
# 6. 토큰 유효성 확인
|
||||||
|
try:
|
||||||
|
access_token = await SocialAccountService().ensure_valid_token(social_account, session)
|
||||||
|
except TokenExpiredError:
|
||||||
|
logger.warning(f"[TOKEN EXPIRED] 재연동 필요 - user_uuid={current_user.user_uuid}")
|
||||||
|
raise YouTubeTokenExpiredError()
|
||||||
|
logger.debug("[6] 토큰 유효성 확인 완료")
|
||||||
|
|
||||||
|
# 7. YouTube Analytics API 호출
|
||||||
|
youtube_service = YouTubeAnalyticsService()
|
||||||
|
raw_data = await youtube_service.fetch_all_metrics(
|
||||||
|
video_ids=video_ids,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
kpi_end_date=kpi_end_date,
|
||||||
|
access_token=access_token,
|
||||||
|
mode=mode,
|
||||||
|
)
|
||||||
|
logger.debug("[7] YouTube Analytics API 호출 완료")
|
||||||
|
|
||||||
|
# 8. TopContent 조립
|
||||||
|
processor = DataProcessor()
|
||||||
|
top_content_rows = raw_data.get("top_videos", {}).get("rows", [])
|
||||||
|
top_content: list[TopContent] = []
|
||||||
|
for row in top_content_rows[:4]:
|
||||||
|
if len(row) < 4:
|
||||||
|
continue
|
||||||
|
video_id, views, likes, comments = row[0], row[1], row[2], row[3]
|
||||||
|
meta = video_lookup.get(video_id)
|
||||||
|
if not meta:
|
||||||
|
continue
|
||||||
|
title, uploaded_at = meta
|
||||||
|
engagement_rate = ((likes + comments) / views * 100) if views > 0 else 0
|
||||||
|
top_content.append(
|
||||||
|
TopContent(
|
||||||
|
id=video_id,
|
||||||
|
title=title,
|
||||||
|
thumbnail=f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg",
|
||||||
|
platform="youtube",
|
||||||
|
views=int(views),
|
||||||
|
engagement=f"{engagement_rate:.1f}%",
|
||||||
|
date=uploaded_at.strftime("%Y.%m.%d"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.debug(f"[8] TopContent 조립 완료 - count={len(top_content)}")
|
||||||
|
|
||||||
|
# 9. 데이터 가공
|
||||||
|
dashboard_data = processor.process(raw_data, top_content, 0, mode=mode, end_date=end_date)
|
||||||
|
logger.debug("[9] 데이터 가공 완료")
|
||||||
|
|
||||||
|
# 10. 캐시 저장
|
||||||
|
cache_payload = json.dumps({"response": dashboard_data.model_dump(mode="json")})
|
||||||
|
cache_success = await set_cache(cache_key, cache_payload, ttl=43200)
|
||||||
|
if cache_success:
|
||||||
|
logger.debug(f"[CACHE SET] 캐시 저장 성공 - key={cache_key}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"[CACHE SET] 캐시 저장 실패 - key={cache_key}")
|
||||||
|
|
||||||
|
# 11. 업로드 영상 수 주입
|
||||||
|
self.inject_video_count(dashboard_data, period_video_count, prev_period_video_count)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[DASHBOARD] 통계 조회 완료 - "
|
||||||
|
f"user_uuid={current_user.user_uuid}, mode={mode}, period={period_desc}, videos={len(video_ids)}"
|
||||||
|
)
|
||||||
|
return dashboard_data
|
||||||
|
|
@ -0,0 +1,542 @@
|
||||||
|
"""
|
||||||
|
YouTube Analytics 데이터 가공 프로세서
|
||||||
|
|
||||||
|
YouTube Analytics API의 원본 데이터를 프론트엔드용 Pydantic 스키마로 변환합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from app.dashboard.schemas import (
|
||||||
|
AudienceData,
|
||||||
|
ContentMetric,
|
||||||
|
DailyData,
|
||||||
|
DashboardResponse,
|
||||||
|
MonthlyData,
|
||||||
|
TopContent,
|
||||||
|
)
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("dashboard")
|
||||||
|
|
||||||
|
_COUNTRY_CODE_MAP: dict[str, str] = {
|
||||||
|
"KR": "대한민국",
|
||||||
|
"US": "미국",
|
||||||
|
"JP": "일본",
|
||||||
|
"CN": "중국",
|
||||||
|
"GB": "영국",
|
||||||
|
"DE": "독일",
|
||||||
|
"FR": "프랑스",
|
||||||
|
"CA": "캐나다",
|
||||||
|
"AU": "호주",
|
||||||
|
"IN": "인도",
|
||||||
|
"ID": "인도네시아",
|
||||||
|
"TH": "태국",
|
||||||
|
"VN": "베트남",
|
||||||
|
"PH": "필리핀",
|
||||||
|
"MY": "말레이시아",
|
||||||
|
"SG": "싱가포르",
|
||||||
|
"TW": "대만",
|
||||||
|
"HK": "홍콩",
|
||||||
|
"BR": "브라질",
|
||||||
|
"MX": "멕시코",
|
||||||
|
"NL": "네덜란드",
|
||||||
|
"BE": "벨기에",
|
||||||
|
"SE": "스웨덴",
|
||||||
|
"NO": "노르웨이",
|
||||||
|
"FI": "핀란드",
|
||||||
|
"DK": "덴마크",
|
||||||
|
"IE": "아일랜드",
|
||||||
|
"PL": "폴란드",
|
||||||
|
"CZ": "체코",
|
||||||
|
"RO": "루마니아",
|
||||||
|
"HU": "헝가리",
|
||||||
|
"SK": "슬로바키아",
|
||||||
|
"SI": "슬로베니아",
|
||||||
|
"HR": "크로아티아",
|
||||||
|
"GR": "그리스",
|
||||||
|
"PT": "포르투갈",
|
||||||
|
"ES": "스페인",
|
||||||
|
"IT": "이탈리아",
|
||||||
|
}
|
||||||
|
|
||||||
|
class DataProcessor:
|
||||||
|
"""YouTube Analytics 데이터 가공 프로세서
|
||||||
|
|
||||||
|
YouTube Analytics API의 원본 JSON 데이터를 DashboardResponse 스키마로 변환합니다.
|
||||||
|
각 섹션별로 데이터 가공 로직을 분리하여 유지보수성을 향상시켰습니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def process(
|
||||||
|
self,
|
||||||
|
raw_data: dict[str, Any],
|
||||||
|
top_content: list[TopContent],
|
||||||
|
period_video_count: int = 0,
|
||||||
|
mode: Literal["day", "month"] = "month",
|
||||||
|
end_date: str = "",
|
||||||
|
) -> DashboardResponse:
|
||||||
|
"""YouTube Analytics API 원본 데이터를 DashboardResponse로 변환
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_data: YouTube Analytics API 응답 데이터 (mode에 따라 키 구성 다름)
|
||||||
|
공통:
|
||||||
|
- kpi: KPI 메트릭 (조회수, 좋아요, 댓글, 시청시간 등)
|
||||||
|
- top_videos: 인기 영상 데이터
|
||||||
|
- demographics: 연령/성별 데이터
|
||||||
|
- region: 지역별 데이터
|
||||||
|
mode="month" 추가:
|
||||||
|
- trend_recent: 최근 12개월 월별 조회수
|
||||||
|
- trend_previous: 이전 12개월 월별 조회수
|
||||||
|
mode="day" 추가:
|
||||||
|
- trend_recent: 최근 30일 일별 조회수
|
||||||
|
- trend_previous: 이전 30일 일별 조회수
|
||||||
|
top_content: TopContent 리스트 (라우터에서 Analytics + DB lookup으로 생성)
|
||||||
|
period_video_count: 조회 기간 내 업로드된 영상 수 (DB에서 집계)
|
||||||
|
mode: 조회 모드 ("month" | "day")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DashboardResponse: 프론트엔드용 대시보드 응답 스키마
|
||||||
|
- mode="month": monthly_data 채움, daily_data=[]
|
||||||
|
- mode="day": daily_data 채움, monthly_data=[]
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> processor = DataProcessor()
|
||||||
|
>>> response = processor.process(
|
||||||
|
... raw_data={
|
||||||
|
... "kpi": {...},
|
||||||
|
... "monthly_recent": {...},
|
||||||
|
... "monthly_previous": {...},
|
||||||
|
... "top_videos": {...},
|
||||||
|
... "demographics": {...},
|
||||||
|
... "region": {...},
|
||||||
|
... },
|
||||||
|
... top_content=[TopContent(...)],
|
||||||
|
... mode="month",
|
||||||
|
... )
|
||||||
|
"""
|
||||||
|
logger.debug(
|
||||||
|
f"[DataProcessor.process] START - "
|
||||||
|
f"top_content_count={len(top_content)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 각 섹션별 데이터 가공 (안전한 딕셔너리 접근)
|
||||||
|
content_metrics = self._build_content_metrics(
|
||||||
|
raw_data.get("kpi", {}),
|
||||||
|
raw_data.get("kpi_previous", {}),
|
||||||
|
period_video_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
if mode == "month":
|
||||||
|
monthly_data = self._merge_monthly_data(
|
||||||
|
raw_data.get("trend_recent", {}),
|
||||||
|
raw_data.get("trend_previous", {}),
|
||||||
|
end_date=end_date,
|
||||||
|
)
|
||||||
|
daily_data: list[DailyData] = []
|
||||||
|
else: # mode == "day"
|
||||||
|
daily_data = self._build_daily_data(
|
||||||
|
raw_data.get("trend_recent", {}),
|
||||||
|
raw_data.get("trend_previous", {}),
|
||||||
|
end_date=end_date,
|
||||||
|
)
|
||||||
|
monthly_data = []
|
||||||
|
|
||||||
|
audience_data = self._build_audience_data(
|
||||||
|
raw_data.get("demographics") or {},
|
||||||
|
raw_data.get("region") or {},
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"[DataProcessor.process] SUCCESS - "
|
||||||
|
f"mode={mode}, metrics={len(content_metrics)}, "
|
||||||
|
f"top_content={len(top_content)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return DashboardResponse(
|
||||||
|
content_metrics=content_metrics,
|
||||||
|
monthly_data=monthly_data,
|
||||||
|
daily_data=daily_data,
|
||||||
|
top_content=top_content,
|
||||||
|
audience_data=audience_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_content_metrics(
|
||||||
|
self,
|
||||||
|
kpi_data: dict[str, Any],
|
||||||
|
kpi_previous_data: dict[str, Any],
|
||||||
|
period_video_count: int = 0,
|
||||||
|
) -> list[ContentMetric]:
|
||||||
|
"""KPI 데이터를 ContentMetric 리스트로 변환
|
||||||
|
|
||||||
|
Args:
|
||||||
|
kpi_data: 최근 기간 KPI 응답
|
||||||
|
rows[0] = [views, likes, comments, shares,
|
||||||
|
estimatedMinutesWatched, averageViewDuration,
|
||||||
|
subscribersGained]
|
||||||
|
kpi_previous_data: 이전 기간 KPI 응답 (증감률 계산용)
|
||||||
|
period_video_count: 조회 기간 내 업로드된 영상 수 (DB에서 집계)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[ContentMetric]: KPI 지표 카드 리스트 (8개)
|
||||||
|
순서: 조회수, 시청시간, 평균 시청시간, 신규 구독자, 좋아요, 댓글, 공유, 업로드 영상
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
f"[DataProcessor._build_content_metrics] START - "
|
||||||
|
f"kpi_keys={list(kpi_data.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = kpi_data.get("rows", [])
|
||||||
|
if not rows or not rows[0]:
|
||||||
|
logger.warning(
|
||||||
|
f"[DataProcessor._build_content_metrics] NO_DATA - " f"rows={rows}"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
row = rows[0]
|
||||||
|
prev_rows = kpi_previous_data.get("rows", [])
|
||||||
|
prev_row = prev_rows[0] if prev_rows else []
|
||||||
|
|
||||||
|
def _get(r: list, i: int, default: float = 0.0) -> float:
|
||||||
|
return r[i] if len(r) > i else default
|
||||||
|
|
||||||
|
def _trend(recent: float, previous: float) -> tuple[float, str]:
|
||||||
|
pct = recent - previous
|
||||||
|
if pct > 0:
|
||||||
|
direction = "up"
|
||||||
|
elif pct < 0:
|
||||||
|
direction = "down"
|
||||||
|
else:
|
||||||
|
direction = "-"
|
||||||
|
return pct, direction
|
||||||
|
|
||||||
|
# 최근 기간
|
||||||
|
views = _get(row, 0)
|
||||||
|
likes = _get(row, 1)
|
||||||
|
comments = _get(row, 2)
|
||||||
|
shares = _get(row, 3)
|
||||||
|
estimated_minutes_watched = _get(row, 4)
|
||||||
|
average_view_duration = _get(row, 5)
|
||||||
|
subscribers_gained = _get(row, 6)
|
||||||
|
|
||||||
|
# 이전 기간
|
||||||
|
prev_views = _get(prev_row, 0)
|
||||||
|
prev_likes = _get(prev_row, 1)
|
||||||
|
prev_comments = _get(prev_row, 2)
|
||||||
|
prev_shares = _get(prev_row, 3)
|
||||||
|
prev_minutes_watched = _get(prev_row, 4)
|
||||||
|
prev_avg_duration = _get(prev_row, 5)
|
||||||
|
prev_subscribers = _get(prev_row, 6)
|
||||||
|
|
||||||
|
views_trend, views_dir = _trend(views, prev_views)
|
||||||
|
watch_trend, watch_dir = _trend(estimated_minutes_watched, prev_minutes_watched)
|
||||||
|
duration_trend, duration_dir = _trend(average_view_duration, prev_avg_duration)
|
||||||
|
subs_trend, subs_dir = _trend(subscribers_gained, prev_subscribers)
|
||||||
|
likes_trend, likes_dir = _trend(likes, prev_likes)
|
||||||
|
comments_trend, comments_dir = _trend(comments, prev_comments)
|
||||||
|
shares_trend, shares_dir = _trend(shares, prev_shares)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[DataProcessor._build_content_metrics] SUCCESS - "
|
||||||
|
f"views={views}({views_trend:+.1f}), "
|
||||||
|
f"watch_time={estimated_minutes_watched}min({watch_trend:+.1f}), "
|
||||||
|
f"subscribers={subscribers_gained}({subs_trend:+.1f})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
ContentMetric(
|
||||||
|
id="total-views",
|
||||||
|
label="조회수",
|
||||||
|
value=float(views),
|
||||||
|
unit="count",
|
||||||
|
trend=round(float(views_trend), 1),
|
||||||
|
trend_direction=views_dir,
|
||||||
|
),
|
||||||
|
ContentMetric(
|
||||||
|
id="total-watch-time",
|
||||||
|
label="시청시간",
|
||||||
|
value=round(estimated_minutes_watched / 60, 1),
|
||||||
|
unit="hours",
|
||||||
|
trend=round(watch_trend / 60, 1),
|
||||||
|
trend_direction=watch_dir,
|
||||||
|
),
|
||||||
|
ContentMetric(
|
||||||
|
id="avg-view-duration",
|
||||||
|
label="평균 시청시간",
|
||||||
|
value=round(average_view_duration / 60, 1),
|
||||||
|
unit="minutes",
|
||||||
|
trend=round(duration_trend / 60, 1),
|
||||||
|
trend_direction=duration_dir,
|
||||||
|
),
|
||||||
|
ContentMetric(
|
||||||
|
id="new-subscribers",
|
||||||
|
label="신규 구독자",
|
||||||
|
value=float(subscribers_gained),
|
||||||
|
unit="count",
|
||||||
|
trend=subs_trend,
|
||||||
|
trend_direction=subs_dir,
|
||||||
|
),
|
||||||
|
ContentMetric(
|
||||||
|
id="likes",
|
||||||
|
label="좋아요",
|
||||||
|
value=float(likes),
|
||||||
|
unit="count",
|
||||||
|
trend=likes_trend,
|
||||||
|
trend_direction=likes_dir,
|
||||||
|
),
|
||||||
|
ContentMetric(
|
||||||
|
id="comments",
|
||||||
|
label="댓글",
|
||||||
|
value=float(comments),
|
||||||
|
unit="count",
|
||||||
|
trend=comments_trend,
|
||||||
|
trend_direction=comments_dir,
|
||||||
|
),
|
||||||
|
ContentMetric(
|
||||||
|
id="shares",
|
||||||
|
label="공유",
|
||||||
|
value=float(shares),
|
||||||
|
unit="count",
|
||||||
|
trend=shares_trend,
|
||||||
|
trend_direction=shares_dir,
|
||||||
|
),
|
||||||
|
ContentMetric(
|
||||||
|
id="uploaded-videos",
|
||||||
|
label="업로드 영상",
|
||||||
|
value=float(period_video_count),
|
||||||
|
unit="count",
|
||||||
|
trend=0.0,
|
||||||
|
trend_direction="-",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _merge_monthly_data(
|
||||||
|
self,
|
||||||
|
data_recent: dict[str, Any],
|
||||||
|
data_previous: dict[str, Any],
|
||||||
|
end_date: str = "",
|
||||||
|
) -> list[MonthlyData]:
|
||||||
|
"""최근 12개월과 이전 12개월의 월별 데이터를 병합
|
||||||
|
|
||||||
|
end_date 기준 12개월을 명시 생성하여 API가 반환하지 않은 월(당월 등)도 0으로 포함합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data_recent: 최근 12개월 월별 조회수 데이터
|
||||||
|
rows = [["2026-01", 150000], ["2026-02", 180000], ...]
|
||||||
|
data_previous: 이전 12개월 월별 조회수 데이터
|
||||||
|
rows = [["2025-01", 120000], ["2025-02", 140000], ...]
|
||||||
|
end_date: 기준 종료일 (YYYY-MM-DD). 미전달 시 오늘 사용
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[MonthlyData]: 월별 비교 데이터 (12개, API 미반환 월은 0)
|
||||||
|
"""
|
||||||
|
logger.debug("[DataProcessor._merge_monthly_data] START")
|
||||||
|
|
||||||
|
rows_recent = data_recent.get("rows", [])
|
||||||
|
rows_previous = data_previous.get("rows", [])
|
||||||
|
|
||||||
|
map_recent = {row[0]: row[1] for row in rows_recent if len(row) >= 2}
|
||||||
|
map_previous = {row[0]: row[1] for row in rows_previous if len(row) >= 2}
|
||||||
|
|
||||||
|
# end_date 기준 12개월 명시 생성 (API 미반환 당월도 0으로 포함)
|
||||||
|
if end_date:
|
||||||
|
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
||||||
|
else:
|
||||||
|
end_dt = datetime.today()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for i in range(11, -1, -1):
|
||||||
|
m = end_dt.month - i
|
||||||
|
y = end_dt.year
|
||||||
|
if m <= 0:
|
||||||
|
m += 12
|
||||||
|
y -= 1
|
||||||
|
month_key = f"{y}-{m:02d}"
|
||||||
|
result.append(
|
||||||
|
MonthlyData(
|
||||||
|
month=f"{m}월",
|
||||||
|
this_year=map_recent.get(month_key, 0),
|
||||||
|
last_year=map_previous.get(f"{y - 1}-{m:02d}", 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[DataProcessor._merge_monthly_data] SUCCESS - count={len(result)}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _build_daily_data(
|
||||||
|
self,
|
||||||
|
data_recent: dict[str, Any],
|
||||||
|
data_previous: dict[str, Any],
|
||||||
|
end_date: str = "",
|
||||||
|
num_days: int = 30,
|
||||||
|
) -> list[DailyData]:
|
||||||
|
"""최근 30일과 이전 30일의 일별 데이터를 병합
|
||||||
|
|
||||||
|
end_date 기준 num_days개 날짜를 직접 생성하여 YouTube API 응답에
|
||||||
|
해당 날짜 row가 없어도 0으로 채웁니다 (X축 누락 방지).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data_recent: 최근 30일 일별 조회수 데이터
|
||||||
|
rows = [["2026-01-20", 5000], ["2026-01-21", 6200], ...]
|
||||||
|
data_previous: 이전 30일 일별 조회수 데이터
|
||||||
|
rows = [["2025-12-21", 4500], ["2025-12-22", 5100], ...]
|
||||||
|
end_date: 최근 기간의 마지막 날 (YYYY-MM-DD). 미전달 시 rows 마지막 날 사용
|
||||||
|
num_days: 표시할 일수 (기본 30)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[DailyData]: 일별 비교 데이터 (num_days개, 데이터 없는 날은 0)
|
||||||
|
"""
|
||||||
|
logger.debug("[DataProcessor._build_daily_data] START")
|
||||||
|
|
||||||
|
rows_recent = data_recent.get("rows", [])
|
||||||
|
rows_previous = data_previous.get("rows", [])
|
||||||
|
|
||||||
|
# 날짜 → 조회수 맵
|
||||||
|
map_recent = {row[0]: row[1] for row in rows_recent if len(row) >= 2}
|
||||||
|
map_previous = {row[0]: row[1] for row in rows_previous if len(row) >= 2}
|
||||||
|
|
||||||
|
# end_date 결정: 전달된 값 우선, 없으면 rows 마지막 날짜 사용
|
||||||
|
if end_date:
|
||||||
|
end_dt = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||||
|
elif rows_recent:
|
||||||
|
end_dt = datetime.strptime(rows_recent[-1][0], "%Y-%m-%d").date()
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"[DataProcessor._build_daily_data] NO_DATA - rows_recent 비어있음"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
start_dt = end_dt - timedelta(days=num_days - 1)
|
||||||
|
|
||||||
|
# 날짜 범위를 직접 생성하여 누락된 날짜도 0으로 채움
|
||||||
|
result = []
|
||||||
|
current = start_dt
|
||||||
|
while current <= end_dt:
|
||||||
|
date_str = current.strftime("%Y-%m-%d")
|
||||||
|
date_label = f"{current.month}/{current.day}"
|
||||||
|
|
||||||
|
this_views = map_recent.get(date_str, 0)
|
||||||
|
|
||||||
|
# 이전 기간: 동일 인덱스 날짜 (current - 30일)
|
||||||
|
prev_date_str = (current - timedelta(days=num_days)).strftime("%Y-%m-%d")
|
||||||
|
last_views = map_previous.get(prev_date_str, 0)
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
DailyData(
|
||||||
|
date=date_label,
|
||||||
|
this_period=int(this_views),
|
||||||
|
last_period=int(last_views),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
current += timedelta(days=1)
|
||||||
|
|
||||||
|
logger.debug(f"[DataProcessor._build_daily_data] SUCCESS - count={len(result)}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _build_audience_data(
|
||||||
|
self,
|
||||||
|
demographics_data: dict[str, Any],
|
||||||
|
geography_data: dict[str, Any],
|
||||||
|
) -> AudienceData:
|
||||||
|
"""시청자 분석 데이터 생성
|
||||||
|
|
||||||
|
연령대별, 성별, 지역별 시청자 분포를 분석합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
demographics_data: 연령/성별 API 응답
|
||||||
|
rows = [["age18-24", "male", 45000], ["age18-24", "female", 55000], ...]
|
||||||
|
geography_data: 지역별 API 응답
|
||||||
|
rows = [["KR", 1000000], ["US", 500000], ...]
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AudienceData: 시청자 분석 데이터
|
||||||
|
- age_groups: 연령대별 비율
|
||||||
|
- gender: 성별 조회수
|
||||||
|
- top_regions: 상위 지역 (5개)
|
||||||
|
"""
|
||||||
|
logger.debug("[DataProcessor._build_audience_data] START")
|
||||||
|
|
||||||
|
# === 연령/성별 데이터 처리 ===
|
||||||
|
demo_rows = demographics_data.get("rows", [])
|
||||||
|
|
||||||
|
age_map: dict[str, float] = {}
|
||||||
|
gender_map_f: dict[str, float] = {"male": 0.0, "female": 0.0}
|
||||||
|
|
||||||
|
for row in demo_rows:
|
||||||
|
if len(row) < 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
age_group = row[0] # "age18-24"
|
||||||
|
gender = row[1] # "male" or "female"
|
||||||
|
viewer_pct = row[2] # viewerPercentage (이미 % 값, 예: 45.5)
|
||||||
|
|
||||||
|
# 연령대별 집계: 남녀 비율 합산 (age18-24 → 18-24)
|
||||||
|
age_label = age_group.replace("age", "")
|
||||||
|
age_map[age_label] = age_map.get(age_label, 0.0) + viewer_pct
|
||||||
|
|
||||||
|
# 성별 집계
|
||||||
|
if gender in gender_map_f:
|
||||||
|
gender_map_f[gender] += viewer_pct
|
||||||
|
|
||||||
|
# 연령대 5개로 통합: 13-17+18-24 → 13-24, 55-64+65- → 55+
|
||||||
|
merged_age: dict[str, float] = {
|
||||||
|
"13-24": age_map.get("13-17", 0.0) + age_map.get("18-24", 0.0),
|
||||||
|
"25-34": age_map.get("25-34", 0.0),
|
||||||
|
"35-44": age_map.get("35-44", 0.0),
|
||||||
|
"45-54": age_map.get("45-54", 0.0),
|
||||||
|
"55+": age_map.get("55-64", 0.0) + age_map.get("65-", 0.0),
|
||||||
|
}
|
||||||
|
age_groups = [
|
||||||
|
{"label": age, "percentage": int(round(pct))}
|
||||||
|
for age, pct in merged_age.items()
|
||||||
|
]
|
||||||
|
gender_map = {k: int(round(v)) for k, v in gender_map_f.items()}
|
||||||
|
|
||||||
|
# === 지역 데이터 처리 ===
|
||||||
|
geo_rows = geography_data.get("rows", [])
|
||||||
|
total_geo_views = sum(row[1] for row in geo_rows if len(row) >= 2)
|
||||||
|
|
||||||
|
merged_geo: defaultdict[str, int] = defaultdict(int)
|
||||||
|
for row in geo_rows:
|
||||||
|
if len(row) >= 2:
|
||||||
|
merged_geo[self._translate_country_code(row[0])] += row[1]
|
||||||
|
|
||||||
|
top_regions = [
|
||||||
|
{
|
||||||
|
"region": region,
|
||||||
|
"percentage": int((views / total_geo_views * 100) if total_geo_views > 0 else 0),
|
||||||
|
}
|
||||||
|
for region, views in sorted(merged_geo.items(), key=lambda x: x[1], reverse=True)[:5]
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[DataProcessor._build_audience_data] SUCCESS - "
|
||||||
|
f"age_groups={len(age_groups)}, regions={len(top_regions)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return AudienceData(
|
||||||
|
age_groups=age_groups,
|
||||||
|
gender=gender_map,
|
||||||
|
top_regions=top_regions,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _translate_country_code(code: str) -> str:
|
||||||
|
"""국가 코드를 한국어로 변환
|
||||||
|
|
||||||
|
ISO 3166-1 alpha-2 국가 코드를 한국어 국가명으로 변환합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: ISO 3166-1 alpha-2 국가 코드 (예: "KR", "US")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 한국어 국가명 (매핑되지 않은 경우 원본 코드 반환)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> _translate_country_code("KR")
|
||||||
|
"대한민국"
|
||||||
|
>>> _translate_country_code("US")
|
||||||
|
"미국"
|
||||||
|
"""
|
||||||
|
return _COUNTRY_CODE_MAP.get(code, "기타")
|
||||||
|
|
@ -0,0 +1,503 @@
|
||||||
|
"""
|
||||||
|
YouTube Analytics API 서비스
|
||||||
|
|
||||||
|
YouTube Analytics API v2를 호출하여 채널 및 영상 통계를 조회합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.dashboard.exceptions import (
|
||||||
|
YouTubeAPIError,
|
||||||
|
YouTubeAuthError,
|
||||||
|
YouTubeQuotaExceededError,
|
||||||
|
)
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("dashboard")
|
||||||
|
|
||||||
|
|
||||||
|
class YouTubeAnalyticsService:
|
||||||
|
"""YouTube Analytics API 호출 서비스
|
||||||
|
|
||||||
|
YouTube Analytics API v2를 사용하여 채널 통계, 영상 성과,
|
||||||
|
시청자 분석 데이터를 조회합니다.
|
||||||
|
|
||||||
|
API 문서:
|
||||||
|
https://developers.google.com/youtube/analytics/reference
|
||||||
|
"""
|
||||||
|
|
||||||
|
BASE_URL = "https://youtubeanalytics.googleapis.com/v2/reports"
|
||||||
|
|
||||||
|
async def fetch_all_metrics(
|
||||||
|
self,
|
||||||
|
video_ids: list[str],
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
access_token: str,
|
||||||
|
mode: Literal["day", "month"] = "month",
|
||||||
|
kpi_end_date: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""YouTube Analytics API 호출을 병렬로 실행
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_ids: YouTube 영상 ID 리스트 (최대 30개, 빈 리스트 허용)
|
||||||
|
start_date: 조회 시작일 (YYYY-MM-DD)
|
||||||
|
end_date: 조회 종료일 (YYYY-MM-DD)
|
||||||
|
access_token: YouTube OAuth 2.0 액세스 토큰
|
||||||
|
mode: 조회 모드 ("month" | "day")
|
||||||
|
kpi_end_date: KPI 집계 종료일 (미전달 시 end_date와 동일)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: API 응답 데이터 (7개 키)
|
||||||
|
- kpi: 최근 기간 KPI 메트릭 (조회수, 좋아요, 댓글 등)
|
||||||
|
- kpi_previous: 이전 기간 KPI 메트릭 (trend 계산용)
|
||||||
|
- trend_recent: 최근 기간 추이 (월별 또는 일별 조회수)
|
||||||
|
- trend_previous: 이전 기간 추이 (전년 또는 이전 30일)
|
||||||
|
- top_videos: 조회수 기준 인기 영상 TOP 4
|
||||||
|
- demographics: 연령/성별 시청자 분포
|
||||||
|
- region: 지역별 조회수 TOP 5
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
YouTubeAPIError: API 호출 실패
|
||||||
|
YouTubeQuotaExceededError: 할당량 초과
|
||||||
|
YouTubeAuthError: 인증 실패
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> service = YouTubeAnalyticsService()
|
||||||
|
>>> data = await service.fetch_all_metrics(
|
||||||
|
... video_ids=["dQw4w9WgXcQ", "jNQXAC9IVRw"],
|
||||||
|
... start_date="2026-01-01",
|
||||||
|
... end_date="2026-12-31",
|
||||||
|
... access_token="ya29.a0..."
|
||||||
|
... )
|
||||||
|
"""
|
||||||
|
logger.debug(
|
||||||
|
f"[1/7] YouTube Analytics API 병렬 호출 시작 - "
|
||||||
|
f"video_count={len(video_ids)}, period={start_date}~{end_date}, mode={mode}"
|
||||||
|
)
|
||||||
|
|
||||||
|
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
||||||
|
# kpi_end_date: KPI/top_videos/demographics/region 호출에 사용
|
||||||
|
# month 모드에서는 현재 월 전체 데이터를 포함하기 위해 end_date(YYYY-MM-01)보다 늦은 날짜 사용
|
||||||
|
# day 모드 또는 미전달 시 end_date와 동일
|
||||||
|
_kpi_end = kpi_end_date if kpi_end_date else end_date
|
||||||
|
|
||||||
|
if mode == "month":
|
||||||
|
# 월별 차트: 라우터에서 이미 YYYY-MM-01 형식으로 계산된 날짜 그대로 사용
|
||||||
|
# recent: start_date ~ end_date (ex. 2025-03-01 ~ 2026-02-01)
|
||||||
|
# previous: 1년 전 동일 기간 (ex. 2024-03-01 ~ 2025-02-01)
|
||||||
|
recent_start = start_date
|
||||||
|
recent_end = end_date
|
||||||
|
previous_start = f"{int(start_date[:4]) - 1}{start_date[4:]}"
|
||||||
|
previous_end = f"{int(end_date[:4]) - 1}{end_date[4:]}"
|
||||||
|
# KPI 이전 기간: _kpi_end 기준 1년 전 (ex. 2026-02-22 → 2025-02-22)
|
||||||
|
previous_kpi_end = f"{int(_kpi_end[:4]) - 1}{_kpi_end[4:]}"
|
||||||
|
logger.debug(
|
||||||
|
f"[월별 데이터] 최근 12개월: {recent_start}~{recent_end}, "
|
||||||
|
f"이전 12개월: {previous_start}~{previous_end}, "
|
||||||
|
f"KPI 조회 종료일: {_kpi_end}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 일별 차트: end_date 기준 최근 30일 / 이전 30일
|
||||||
|
day_recent_end = end_date
|
||||||
|
day_recent_start = (end_dt - timedelta(days=29)).strftime("%Y-%m-%d")
|
||||||
|
day_previous_end = (end_dt - timedelta(days=30)).strftime("%Y-%m-%d")
|
||||||
|
day_previous_start = (end_dt - timedelta(days=59)).strftime("%Y-%m-%d")
|
||||||
|
logger.debug(
|
||||||
|
f"[일별 데이터] 최근 30일: {day_recent_start}~{day_recent_end}, "
|
||||||
|
f"이전 30일: {day_previous_start}~{day_previous_end}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 7개 API 호출 태스크 생성 (mode별 선택적)
|
||||||
|
# [0] KPI(최근), [1] KPI(이전), [2] 추이(최근), [3] 추이(이전), [4] 인기영상, [5] 인구통계, [6] 지역
|
||||||
|
# mode=month: [2][3] = 월별 데이터 (YYYY-MM-01 형식 필요)
|
||||||
|
# mode=day: [2][3] = 일별 데이터
|
||||||
|
if mode == "month":
|
||||||
|
tasks = [
|
||||||
|
self._fetch_kpi(video_ids, start_date, _kpi_end, access_token),
|
||||||
|
self._fetch_kpi(video_ids, previous_start, previous_kpi_end, access_token),
|
||||||
|
self._fetch_monthly_data(video_ids, recent_start, recent_end, access_token),
|
||||||
|
self._fetch_monthly_data(video_ids, previous_start, previous_end, access_token),
|
||||||
|
self._fetch_top_videos(video_ids, start_date, _kpi_end, access_token),
|
||||||
|
self._fetch_demographics(start_date, _kpi_end, access_token),
|
||||||
|
self._fetch_region(start_date, _kpi_end, access_token),
|
||||||
|
]
|
||||||
|
else: # mode == "day"
|
||||||
|
tasks = [
|
||||||
|
self._fetch_kpi(video_ids, start_date, end_date, access_token),
|
||||||
|
self._fetch_kpi(video_ids, day_previous_start, day_previous_end, access_token),
|
||||||
|
self._fetch_daily_data(video_ids, day_recent_start, day_recent_end, access_token),
|
||||||
|
self._fetch_daily_data(video_ids, day_previous_start, day_previous_end, access_token),
|
||||||
|
self._fetch_top_videos(video_ids, start_date, end_date, access_token),
|
||||||
|
self._fetch_demographics(start_date, end_date, access_token),
|
||||||
|
self._fetch_region(start_date, end_date, access_token),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 병렬 실행
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
# 에러 체크 (YouTubeAuthError, YouTubeQuotaExceededError는 원형 그대로 전파)
|
||||||
|
# demographics(index 5)는 YouTubeAPIError 시 None으로 허용 (YouTube 서버 간헐적 오류 대응)
|
||||||
|
OPTIONAL_INDICES = {5, 6} # demographics, region
|
||||||
|
results = list(results)
|
||||||
|
for i, result in enumerate(results):
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
logger.error(
|
||||||
|
f"[YouTubeAnalyticsService] API 호출 {i+1}/7 실패: {result.__class__.__name__}"
|
||||||
|
)
|
||||||
|
if isinstance(result, (YouTubeAuthError, YouTubeQuotaExceededError)):
|
||||||
|
raise result
|
||||||
|
if i in OPTIONAL_INDICES and isinstance(result, YouTubeAPIError):
|
||||||
|
logger.warning(
|
||||||
|
f"[YouTubeAnalyticsService] 선택적 API 호출 {i+1}/7 실패, None으로 처리: {result}"
|
||||||
|
)
|
||||||
|
results[i] = None
|
||||||
|
continue
|
||||||
|
raise YouTubeAPIError(f"데이터 조회 실패: {result.__class__.__name__}")
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"[7/7] YouTube Analytics API 병렬 호출 완료 - mode={mode}, 성공률 100%"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 각 API 호출 결과 디버그 로깅
|
||||||
|
labels = [
|
||||||
|
"kpi",
|
||||||
|
"kpi_previous",
|
||||||
|
"trend_recent",
|
||||||
|
"trend_previous",
|
||||||
|
"top_videos",
|
||||||
|
"demographics",
|
||||||
|
"region",
|
||||||
|
]
|
||||||
|
for label, result in zip(labels, results):
|
||||||
|
rows = result.get("rows") if isinstance(result, dict) else None
|
||||||
|
row_count = len(rows) if rows else 0
|
||||||
|
preview = rows[:2] if rows else []
|
||||||
|
logger.debug(
|
||||||
|
f"[fetch_all_metrics] {label}: row_count={row_count}, preview={preview}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"kpi": results[0],
|
||||||
|
"kpi_previous": results[1],
|
||||||
|
"trend_recent": results[2],
|
||||||
|
"trend_previous": results[3],
|
||||||
|
"top_videos": results[4],
|
||||||
|
"demographics": results[5],
|
||||||
|
"region": results[6],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _fetch_kpi(
|
||||||
|
self,
|
||||||
|
video_ids: list[str],
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
access_token: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""전체 KPI 메트릭 조회 (contentMetrics용)
|
||||||
|
|
||||||
|
조회수, 좋아요, 댓글, 공유, 시청 시간, 구독자 증감 등
|
||||||
|
핵심 성과 지표를 조회합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_ids: YouTube 영상 ID 리스트
|
||||||
|
start_date: 조회 시작일 (YYYY-MM-DD)
|
||||||
|
end_date: 조회 종료일 (YYYY-MM-DD)
|
||||||
|
access_token: OAuth 2.0 액세스 토큰
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: YouTube Analytics API 응답
|
||||||
|
rows[0] = [views, likes, comments, shares,
|
||||||
|
estimatedMinutesWatched, averageViewDuration,
|
||||||
|
subscribersGained]
|
||||||
|
|
||||||
|
"""
|
||||||
|
logger.debug(
|
||||||
|
f"[YouTubeAnalyticsService._fetch_kpi] START - video_count={len(video_ids)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"ids": "channel==MINE",
|
||||||
|
"startDate": start_date,
|
||||||
|
"endDate": end_date,
|
||||||
|
"metrics": "views,likes,comments,shares,estimatedMinutesWatched,averageViewDuration,subscribersGained",
|
||||||
|
"filters": f"video=={','.join(video_ids)}",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await self._call_api(params, access_token)
|
||||||
|
logger.debug("[YouTubeAnalyticsService._fetch_kpi] SUCCESS")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _fetch_monthly_data(
|
||||||
|
self,
|
||||||
|
video_ids: list[str],
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
access_token: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""월별 조회수 데이터 조회
|
||||||
|
|
||||||
|
지정된 기간의 월별 조회수를 조회합니다.
|
||||||
|
최근 12개월과 이전 12개월을 각각 조회하여 비교합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_ids: YouTube 영상 ID 리스트
|
||||||
|
start_date: 조회 시작일 (YYYY-MM-DD)
|
||||||
|
end_date: 조회 종료일 (YYYY-MM-DD)
|
||||||
|
access_token: OAuth 2.0 액세스 토큰
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: YouTube Analytics API 응답
|
||||||
|
rows = [["2026-01", 150000], ["2026-02", 180000], ...]
|
||||||
|
"""
|
||||||
|
logger.debug(
|
||||||
|
f"[YouTubeAnalyticsService._fetch_monthly_data] START - "
|
||||||
|
f"period={start_date}~{end_date}"
|
||||||
|
)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"ids": "channel==MINE",
|
||||||
|
"startDate": start_date,
|
||||||
|
"endDate": end_date,
|
||||||
|
"dimensions": "month",
|
||||||
|
"metrics": "views",
|
||||||
|
"filters": f"video=={','.join(video_ids)}",
|
||||||
|
"sort": "month",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await self._call_api(params, access_token)
|
||||||
|
logger.debug(
|
||||||
|
f"[YouTubeAnalyticsService._fetch_monthly_data] SUCCESS - "
|
||||||
|
f"period={start_date}~{end_date}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _fetch_daily_data(
|
||||||
|
self,
|
||||||
|
video_ids: list[str],
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
access_token: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""일별 조회수 데이터 조회
|
||||||
|
|
||||||
|
지정된 기간의 일별 조회수를 조회합니다.
|
||||||
|
최근 30일과 이전 30일을 각각 조회하여 비교합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_ids: YouTube 영상 ID 리스트
|
||||||
|
start_date: 조회 시작일 (YYYY-MM-DD)
|
||||||
|
end_date: 조회 종료일 (YYYY-MM-DD)
|
||||||
|
access_token: OAuth 2.0 액세스 토큰
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: YouTube Analytics API 응답
|
||||||
|
rows = [["2026-01-18", 5000], ["2026-01-19", 6200], ...]
|
||||||
|
"""
|
||||||
|
logger.debug(
|
||||||
|
f"[YouTubeAnalyticsService._fetch_daily_data] START - "
|
||||||
|
f"period={start_date}~{end_date}"
|
||||||
|
)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"ids": "channel==MINE",
|
||||||
|
"startDate": start_date,
|
||||||
|
"endDate": end_date,
|
||||||
|
"dimensions": "day",
|
||||||
|
"metrics": "views",
|
||||||
|
"filters": f"video=={','.join(video_ids)}",
|
||||||
|
"sort": "day",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await self._call_api(params, access_token)
|
||||||
|
logger.debug(
|
||||||
|
f"[YouTubeAnalyticsService._fetch_daily_data] SUCCESS - "
|
||||||
|
f"period={start_date}~{end_date}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _fetch_top_videos(
|
||||||
|
self,
|
||||||
|
video_ids: list[str],
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
access_token: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""영상별 조회수 조회 (topContent용)
|
||||||
|
|
||||||
|
조회수 기준 상위 4개 영상의 성과 데이터를 조회합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
video_ids: YouTube 영상 ID 리스트
|
||||||
|
start_date: 조회 시작일 (YYYY-MM-DD)
|
||||||
|
end_date: 조회 종료일 (YYYY-MM-DD)
|
||||||
|
access_token: OAuth 2.0 액세스 토큰
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: YouTube Analytics API 응답
|
||||||
|
rows = [["video_id", views, likes, comments], ...]
|
||||||
|
조회수 내림차순으로 정렬된 상위 4개 영상
|
||||||
|
"""
|
||||||
|
logger.debug("[YouTubeAnalyticsService._fetch_top_videos] START")
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"ids": "channel==MINE",
|
||||||
|
"startDate": start_date,
|
||||||
|
"endDate": end_date,
|
||||||
|
"dimensions": "video",
|
||||||
|
"metrics": "views,likes,comments",
|
||||||
|
"filters": f"video=={','.join(video_ids)}",
|
||||||
|
"sort": "-views",
|
||||||
|
"maxResults": "4",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await self._call_api(params, access_token)
|
||||||
|
logger.debug("[YouTubeAnalyticsService._fetch_top_videos] SUCCESS")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _fetch_demographics(
|
||||||
|
self,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
access_token: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""연령/성별 분포 조회 (채널 전체 기준)
|
||||||
|
|
||||||
|
시청자의 연령대별, 성별 시청 비율을 조회합니다.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
YouTube Analytics API 제약: ageGroup/gender 차원은 video 필터와 혼용 불가.
|
||||||
|
채널 전체 시청자 기준 데이터를 반환합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: 조회 시작일 (YYYY-MM-DD)
|
||||||
|
end_date: 조회 종료일 (YYYY-MM-DD)
|
||||||
|
access_token: OAuth 2.0 액세스 토큰
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: YouTube Analytics API 응답
|
||||||
|
rows = [["age18-24", "female", 45.5], ["age18-24", "male", 32.1], ...]
|
||||||
|
"""
|
||||||
|
logger.debug("[YouTubeAnalyticsService._fetch_demographics] START")
|
||||||
|
|
||||||
|
# Demographics 보고서는 video 필터 미지원 → 채널 전체 기준 데이터
|
||||||
|
# 지원 filters: country, province, continent, subContinent, liveOrOnDemand, subscribedStatus
|
||||||
|
params = {
|
||||||
|
"ids": "channel==MINE",
|
||||||
|
"startDate": start_date,
|
||||||
|
"endDate": end_date,
|
||||||
|
"dimensions": "ageGroup,gender",
|
||||||
|
"metrics": "viewerPercentage",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await self._call_api(params, access_token)
|
||||||
|
logger.debug("[YouTubeAnalyticsService._fetch_demographics] SUCCESS")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _fetch_region(
|
||||||
|
self,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
access_token: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""지역별 조회수 조회
|
||||||
|
|
||||||
|
지역별 조회수 분포를 조회합니다 (상위 5개).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_date: 조회 시작일 (YYYY-MM-DD)
|
||||||
|
end_date: 조회 종료일 (YYYY-MM-DD)
|
||||||
|
access_token: OAuth 2.0 액세스 토큰
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: YouTube Analytics API 응답
|
||||||
|
rows = [["KR", 1000000], ["US", 500000], ...]
|
||||||
|
조회수 내림차순으로 정렬된 상위 5개 국가
|
||||||
|
"""
|
||||||
|
logger.debug("[YouTubeAnalyticsService._fetch_region] START")
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"ids": "channel==MINE",
|
||||||
|
"startDate": start_date,
|
||||||
|
"endDate": end_date,
|
||||||
|
"dimensions": "country",
|
||||||
|
"metrics": "views",
|
||||||
|
"sort": "-views",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await self._call_api(params, access_token)
|
||||||
|
logger.debug("[YouTubeAnalyticsService._fetch_region] SUCCESS")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _call_api(
|
||||||
|
self,
|
||||||
|
params: dict[str, str],
|
||||||
|
access_token: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""YouTube Analytics API 호출 공통 로직
|
||||||
|
|
||||||
|
모든 API 호출에 공통적으로 사용되는 HTTP 요청 로직입니다.
|
||||||
|
인증 헤더 추가, 에러 처리, 응답 파싱을 담당합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
params: API 요청 파라미터 (dimensions, metrics, filters 등)
|
||||||
|
access_token: OAuth 2.0 액세스 토큰
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, Any]: YouTube Analytics API JSON 응답
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
YouTubeQuotaExceededError: 할당량 초과 (429)
|
||||||
|
YouTubeAuthError: 인증 실패 (401, 403)
|
||||||
|
YouTubeAPIError: 기타 API 오류
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- 타임아웃: 30초
|
||||||
|
- 할당량 초과 시 자동으로 YouTubeQuotaExceededError 발생
|
||||||
|
- 인증 실패 시 자동으로 YouTubeAuthError 발생
|
||||||
|
"""
|
||||||
|
headers = {"Authorization": f"Bearer {access_token}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.get(
|
||||||
|
self.BASE_URL,
|
||||||
|
params=params,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 할당량 초과 체크
|
||||||
|
if response.status_code == 429:
|
||||||
|
logger.warning("[YouTubeAnalyticsService._call_api] QUOTA_EXCEEDED")
|
||||||
|
raise YouTubeQuotaExceededError()
|
||||||
|
|
||||||
|
# 인증 실패 체크
|
||||||
|
if response.status_code in (401, 403):
|
||||||
|
logger.warning(
|
||||||
|
f"[YouTubeAnalyticsService._call_api] AUTH_FAILED - status={response.status_code}"
|
||||||
|
)
|
||||||
|
raise YouTubeAuthError(f"YouTube 인증 실패: {response.status_code}")
|
||||||
|
|
||||||
|
# HTTP 에러 체크
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
except (YouTubeAuthError, YouTubeQuotaExceededError):
|
||||||
|
raise # 이미 처리된 예외는 그대로 전파
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(
|
||||||
|
f"[YouTubeAnalyticsService._call_api] HTTP_ERROR - "
|
||||||
|
f"status={e.response.status_code}, body={e.response.text[:500]}"
|
||||||
|
)
|
||||||
|
raise YouTubeAPIError(f"HTTP {e.response.status_code}")
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"[YouTubeAnalyticsService._call_api] REQUEST_ERROR - {e}")
|
||||||
|
raise YouTubeAPIError(f"네트워크 오류: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[YouTubeAnalyticsService._call_api] UNEXPECTED_ERROR - {e}")
|
||||||
|
raise YouTubeAPIError(f"알 수 없는 오류: {e}")
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""
|
||||||
|
Dashboard Background Tasks
|
||||||
|
|
||||||
|
업로드 완료 시 Dashboard 테이블에 레코드를 삽입하는 백그라운드 태스크입니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.dialects.mysql import insert
|
||||||
|
|
||||||
|
from app.dashboard.models import Dashboard
|
||||||
|
from app.database.session import BackgroundSessionLocal
|
||||||
|
from app.social.models import SocialUpload
|
||||||
|
from app.user.models import SocialAccount
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def insert_dashboard(upload_id: int) -> None:
|
||||||
|
"""
|
||||||
|
Dashboard 레코드 삽입
|
||||||
|
|
||||||
|
SocialUpload(id=upload_id) 완료 데이터를 DB에서 조회하여 Dashboard에 삽입합니다.
|
||||||
|
UniqueConstraint(platform_video_id, platform_user_id) 충돌 시 스킵(INSERT IGNORE).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with BackgroundSessionLocal() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(
|
||||||
|
SocialUpload.user_uuid,
|
||||||
|
SocialUpload.platform,
|
||||||
|
SocialUpload.platform_video_id,
|
||||||
|
SocialUpload.platform_url,
|
||||||
|
SocialUpload.title,
|
||||||
|
SocialUpload.uploaded_at,
|
||||||
|
SocialAccount.platform_user_id,
|
||||||
|
)
|
||||||
|
.join(SocialAccount, SocialUpload.social_account_id == SocialAccount.id)
|
||||||
|
.where(SocialUpload.id == upload_id)
|
||||||
|
)
|
||||||
|
row = result.one_or_none()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
logger.warning(f"[dashboard] upload_id={upload_id} 데이터 없음")
|
||||||
|
return
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
insert(Dashboard)
|
||||||
|
.values(
|
||||||
|
user_uuid=row.user_uuid,
|
||||||
|
platform=row.platform,
|
||||||
|
platform_user_id=row.platform_user_id,
|
||||||
|
platform_video_id=row.platform_video_id,
|
||||||
|
platform_url=row.platform_url,
|
||||||
|
title=row.title,
|
||||||
|
uploaded_at=row.uploaded_at,
|
||||||
|
)
|
||||||
|
.prefix_with("IGNORE")
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[dashboard] 삽입 완료 - "
|
||||||
|
f"upload_id={upload_id}, platform_video_id={row.platform_video_id}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[dashboard] 삽입 실패 - upload_id={upload_id}, error={e}"
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
"""
|
||||||
|
Redis 캐싱 유틸리티
|
||||||
|
|
||||||
|
Dashboard API 성능 최적화를 위한 Redis 캐싱 기능을 제공합니다.
|
||||||
|
YouTube Analytics API 호출 결과를 캐싱하여 중복 요청을 방지합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from redis.asyncio import Redis
|
||||||
|
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
from config import db_settings
|
||||||
|
|
||||||
|
logger = get_logger("redis_cache")
|
||||||
|
|
||||||
|
# Dashboard 전용 Redis 클라이언트 (db=3 사용)
|
||||||
|
_cache_client = Redis(
|
||||||
|
host=db_settings.REDIS_HOST,
|
||||||
|
port=db_settings.REDIS_PORT,
|
||||||
|
db=3,
|
||||||
|
decode_responses=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_cache(key: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Redis 캐시에서 값을 조회합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 캐시 키
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
캐시된 값 (문자열) 또는 None (캐시 미스)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> cached_data = await get_cache("dashboard:user123:2026-01-01:2026-12-31")
|
||||||
|
>>> if cached_data:
|
||||||
|
>>> return json.loads(cached_data)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.debug(f"[GET_CACHE] 캐시 조회 시작 - key: {key}")
|
||||||
|
value = await _cache_client.get(key)
|
||||||
|
|
||||||
|
if value:
|
||||||
|
logger.debug(f"[GET_CACHE] 캐시 HIT - key: {key}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"[GET_CACHE] 캐시 MISS - key: {key}")
|
||||||
|
|
||||||
|
return value
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[GET_CACHE] 캐시 조회 실패 - key: {key}, error: {e}")
|
||||||
|
return None # 캐시 실패 시 None 반환 (원본 데이터 조회하도록 유도)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_cache(key: str, value: str, ttl: int = 43200) -> bool:
|
||||||
|
"""
|
||||||
|
Redis 캐시에 값을 저장합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 캐시 키
|
||||||
|
value: 저장할 값 (문자열)
|
||||||
|
ttl: 캐시 만료 시간 (초). 기본값: 43200초 (12시간)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
성공 여부
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> import json
|
||||||
|
>>> data = {"views": 1000, "likes": 50}
|
||||||
|
>>> await set_cache("dashboard:user123:2026-01-01:2026-12-31", json.dumps(data), ttl=3600)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.debug(f"[SET_CACHE] 캐시 저장 시작 - key: {key}, ttl: {ttl}s")
|
||||||
|
await _cache_client.setex(key, ttl, value)
|
||||||
|
logger.debug(f"[SET_CACHE] 캐시 저장 성공 - key: {key}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[SET_CACHE] 캐시 저장 실패 - key: {key}, error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_cache(key: str) -> bool:
|
||||||
|
"""
|
||||||
|
Redis 캐시에서 값을 삭제합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: 삭제할 캐시 키
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
성공 여부
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> await delete_cache("dashboard:user123:2026-01-01:2026-12-31")
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.debug(f"[DELETE_CACHE] 캐시 삭제 시작 - key: {key}")
|
||||||
|
deleted_count = await _cache_client.delete(key)
|
||||||
|
logger.debug(
|
||||||
|
f"[DELETE_CACHE] 캐시 삭제 완료 - key: {key}, deleted: {deleted_count}"
|
||||||
|
)
|
||||||
|
return deleted_count > 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[DELETE_CACHE] 캐시 삭제 실패 - key: {key}, error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_cache_pattern(pattern: str) -> int:
|
||||||
|
"""
|
||||||
|
패턴에 매칭되는 모든 캐시 키를 삭제합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern: 삭제할 키 패턴 (예: "dashboard:user123:*")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
삭제된 키 개수
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> # 특정 사용자의 모든 대시보드 캐시 삭제
|
||||||
|
>>> deleted = await delete_cache_pattern("dashboard:user123:*")
|
||||||
|
>>> print(f"{deleted}개의 캐시 삭제됨")
|
||||||
|
|
||||||
|
Note:
|
||||||
|
대량의 키 삭제 시 성능에 영향을 줄 수 있으므로 주의해서 사용하세요.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.debug(f"[DELETE_CACHE_PATTERN] 패턴 캐시 삭제 시작 - pattern: {pattern}")
|
||||||
|
|
||||||
|
# 패턴에 매칭되는 모든 키 조회
|
||||||
|
keys = []
|
||||||
|
async for key in _cache_client.scan_iter(match=pattern):
|
||||||
|
keys.append(key)
|
||||||
|
|
||||||
|
if not keys:
|
||||||
|
logger.debug(f"[DELETE_CACHE_PATTERN] 삭제할 키 없음 - pattern: {pattern}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# 모든 키 삭제
|
||||||
|
deleted_count = await _cache_client.delete(*keys)
|
||||||
|
logger.debug(
|
||||||
|
f"[DELETE_CACHE_PATTERN] 패턴 캐시 삭제 완료 - "
|
||||||
|
f"pattern: {pattern}, deleted: {deleted_count}"
|
||||||
|
)
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[DELETE_CACHE_PATTERN] 패턴 캐시 삭제 실패 - pattern: {pattern}, error: {e}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def close_cache_client():
|
||||||
|
"""
|
||||||
|
Redis 클라이언트 연결을 종료합니다.
|
||||||
|
|
||||||
|
애플리케이션 종료 시 호출되어야 합니다.
|
||||||
|
main.py의 shutdown 이벤트 핸들러에서 사용하세요.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> # main.py
|
||||||
|
>>> @app.on_event("shutdown")
|
||||||
|
>>> async def shutdown_event():
|
||||||
|
>>> await close_cache_client()
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("[CLOSE_CACHE_CLIENT] Redis 캐시 클라이언트 종료 중...")
|
||||||
|
await _cache_client.close()
|
||||||
|
logger.info("[CLOSE_CACHE_CLIENT] Redis 캐시 클라이언트 종료 완료")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[CLOSE_CACHE_CLIENT] Redis 캐시 클라이언트 종료 실패 - error: {e}"
|
||||||
|
)
|
||||||
|
|
@ -6,6 +6,7 @@ from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from config import db_settings
|
from config import db_settings
|
||||||
|
import traceback
|
||||||
|
|
||||||
logger = get_logger("database")
|
logger = get_logger("database")
|
||||||
|
|
||||||
|
|
@ -73,12 +74,13 @@ async def create_db_tables():
|
||||||
|
|
||||||
# 모델 import (테이블 메타데이터 등록용)
|
# 모델 import (테이블 메타데이터 등록용)
|
||||||
from app.user.models import User, RefreshToken, SocialAccount # noqa: F401
|
from app.user.models import User, RefreshToken, SocialAccount # noqa: F401
|
||||||
from app.home.models import Image, Project, MarketingIntel # noqa: F401
|
from app.home.models import Image, Project, MarketingIntel, ImageTag # noqa: F401
|
||||||
from app.lyric.models import Lyric # noqa: F401
|
from app.lyric.models import Lyric # noqa: F401
|
||||||
from app.song.models import Song, SongTimestamp # noqa: F401
|
from app.song.models import Song, SongTimestamp # noqa: F401
|
||||||
from app.video.models import Video # noqa: F401
|
from app.video.models import Video # noqa: F401
|
||||||
from app.sns.models import SNSUploadTask # noqa: F401
|
from app.sns.models import SNSUploadTask # noqa: F401
|
||||||
from app.social.models import SocialUpload # noqa: F401
|
from app.social.models import SocialUpload # noqa: F401
|
||||||
|
from app.dashboard.models import Dashboard # noqa: F401
|
||||||
|
|
||||||
# 생성할 테이블 목록
|
# 생성할 테이블 목록
|
||||||
tables_to_create = [
|
tables_to_create = [
|
||||||
|
|
@ -94,6 +96,8 @@ async def create_db_tables():
|
||||||
SNSUploadTask.__table__,
|
SNSUploadTask.__table__,
|
||||||
SocialUpload.__table__,
|
SocialUpload.__table__,
|
||||||
MarketingIntel.__table__,
|
MarketingIntel.__table__,
|
||||||
|
Dashboard.__table__,
|
||||||
|
ImageTag.__table__,
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info("Creating database tables...")
|
logger.info("Creating database tables...")
|
||||||
|
|
@ -168,6 +172,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
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"
|
||||||
)
|
)
|
||||||
|
logger.debug(traceback.format_exc())
|
||||||
raise e
|
raise e
|
||||||
finally:
|
finally:
|
||||||
total_time = time.perf_counter() - start_time
|
total_time = time.perf_counter() - start_time
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,10 @@ import aiofiles
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from app.database.session import get_session, AsyncSessionLocal
|
from app.database.session import get_session, AsyncSessionLocal
|
||||||
from app.home.models import Image, MarketingIntel
|
from app.home.models import Image, MarketingIntel, ImageTag
|
||||||
from app.user.dependencies.auth import get_current_user
|
from app.user.dependencies.auth import get_current_user
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.home.schemas.home_schema import (
|
from app.home.schemas.home_schema import (
|
||||||
|
|
@ -29,12 +30,13 @@ from app.home.schemas.home_schema import (
|
||||||
)
|
)
|
||||||
from app.home.services.naver_search import naver_search_client
|
from app.home.services.naver_search import naver_search_client
|
||||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||||
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
from app.utils.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
||||||
from app.utils.common import generate_task_id
|
from app.utils.common import generate_task_id
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.nvMapScraper import NvMapScraper, GraphQLException
|
from app.utils.nvMapScraper import NvMapScraper, GraphQLException, URLNotFoundException
|
||||||
from app.utils.nvMapPwScraper import NvMapPwScraper
|
from app.utils.nvMapPwScraper import NvMapPwScraper
|
||||||
from app.utils.prompts.prompts import marketing_prompt
|
from app.utils.prompts.prompts import marketing_prompt
|
||||||
|
from app.utils.autotag import autotag_images
|
||||||
from config import MEDIA_ROOT
|
from config import MEDIA_ROOT
|
||||||
|
|
||||||
# 로거 설정
|
# 로거 설정
|
||||||
|
|
@ -218,6 +220,15 @@ async def _crawling_logic(
|
||||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
detail=f"네이버 지도 크롤링에 실패했습니다: {e}",
|
detail=f"네이버 지도 크롤링에 실패했습니다: {e}",
|
||||||
)
|
)
|
||||||
|
except URLNotFoundException as e:
|
||||||
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
|
logger.error(
|
||||||
|
f"[crawling] Step 1 FAILED - 크롤링 실패: {e} ({step1_elapsed:.1f}ms)"
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Place ID를 확인할 수 없습니다. URL을 확인하세요. : {e}",
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
@ -451,255 +462,6 @@ IMAGES_JSON_EXAMPLE = """[
|
||||||
{"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"}
|
{"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"}
|
||||||
]"""
|
]"""
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/image/upload/server",
|
|
||||||
include_in_schema=False,
|
|
||||||
summary="이미지 업로드 (로컬 서버)",
|
|
||||||
description="""
|
|
||||||
이미지를 로컬 서버(media 폴더)에 업로드하고 새로운 task_id를 생성합니다.
|
|
||||||
|
|
||||||
## 요청 방식
|
|
||||||
multipart/form-data 형식으로 전송합니다.
|
|
||||||
|
|
||||||
## 요청 필드
|
|
||||||
- **images_json**: 외부 이미지 URL 목록 (JSON 문자열, 선택)
|
|
||||||
- **files**: 이미지 바이너리 파일 목록 (선택)
|
|
||||||
|
|
||||||
**주의**: images_json 또는 files 중 최소 하나는 반드시 전달해야 합니다.
|
|
||||||
|
|
||||||
## 지원 이미지 확장자
|
|
||||||
jpg, jpeg, png, webp, heic, heif
|
|
||||||
|
|
||||||
## images_json 예시
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{"url": "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg"},
|
|
||||||
{"url": "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg"},
|
|
||||||
{"url": "https://naverbooking-phinf.pstatic.net/20240514_92/17156880307484bvpH_JPEG/3.jpg"},
|
|
||||||
{"url": "https://naverbooking-phinf.pstatic.net/20240514_7/1715688031000y8Y5q_JPEG/4.jpg"},
|
|
||||||
{"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 바이너리 파일 업로드 테스트 방법
|
|
||||||
|
|
||||||
### 1. Swagger UI에서 테스트
|
|
||||||
1. 이 엔드포인트의 "Try it out" 버튼 클릭
|
|
||||||
2. task_id 입력 (예: test-task-001)
|
|
||||||
3. files 항목에서 "Add item" 클릭하여 로컬 이미지 파일 선택
|
|
||||||
4. (선택) images_json에 URL 목록 JSON 입력
|
|
||||||
5. "Execute" 버튼 클릭
|
|
||||||
|
|
||||||
### 2. cURL로 테스트
|
|
||||||
```bash
|
|
||||||
# 바이너리 파일만 업로드
|
|
||||||
curl -X POST "http://localhost:8000/image/upload/server/test-task-001" \\
|
|
||||||
-F "files=@/path/to/image1.jpg" \\
|
|
||||||
-F "files=@/path/to/image2.png"
|
|
||||||
|
|
||||||
# URL + 바이너리 파일 동시 업로드
|
|
||||||
curl -X POST "http://localhost:8000/image/upload/server/test-task-001" \\
|
|
||||||
-F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\
|
|
||||||
-F "files=@/path/to/local_image.jpg"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Python requests로 테스트
|
|
||||||
```python
|
|
||||||
import requests
|
|
||||||
|
|
||||||
url = "http://localhost:8000/image/upload/server/test-task-001"
|
|
||||||
files = [
|
|
||||||
("files", ("image1.jpg", open("image1.jpg", "rb"), "image/jpeg")),
|
|
||||||
("files", ("image2.png", open("image2.png", "rb"), "image/png")),
|
|
||||||
]
|
|
||||||
data = {
|
|
||||||
"images_json": '[{"url": "https://example.com/image.jpg"}]'
|
|
||||||
}
|
|
||||||
response = requests.post(url, files=files, data=data)
|
|
||||||
print(response.json())
|
|
||||||
```
|
|
||||||
|
|
||||||
## 반환 정보
|
|
||||||
- **task_id**: 작업 고유 식별자
|
|
||||||
- **total_count**: 총 업로드된 이미지 개수
|
|
||||||
- **url_count**: URL로 등록된 이미지 개수 (Image 테이블에 외부 URL 그대로 저장)
|
|
||||||
- **file_count**: 파일로 업로드된 이미지 개수 (media 폴더에 저장)
|
|
||||||
- **saved_count**: Image 테이블에 저장된 row 수
|
|
||||||
- **images**: 업로드된 이미지 목록
|
|
||||||
- **source**: "url" (외부 URL) 또는 "file" (로컬 서버 저장)
|
|
||||||
|
|
||||||
## 저장 경로
|
|
||||||
- 바이너리 파일: /media/image/{날짜}/{uuid7}/{파일명}
|
|
||||||
- URL 이미지: 외부 URL 그대로 Image 테이블에 저장
|
|
||||||
|
|
||||||
## 반환 정보
|
|
||||||
- **task_id**: 새로 생성된 작업 고유 식별자
|
|
||||||
- **image_urls**: Image 테이블에 저장된 현재 task_id의 이미지 URL 목록
|
|
||||||
""",
|
|
||||||
response_model=ImageUploadResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "이미지 업로드 성공"},
|
|
||||||
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
|
|
||||||
},
|
|
||||||
tags=["Image-Server"],
|
|
||||||
)
|
|
||||||
async def upload_images(
|
|
||||||
images_json: Optional[str] = Form(
|
|
||||||
default=None,
|
|
||||||
description="외부 이미지 URL 목록 (JSON 문자열)",
|
|
||||||
examples=[IMAGES_JSON_EXAMPLE],
|
|
||||||
),
|
|
||||||
files: Optional[list[UploadFile]] = File(
|
|
||||||
default=None, description="이미지 바이너리 파일 목록"
|
|
||||||
),
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> ImageUploadResponse:
|
|
||||||
"""이미지 업로드 (URL + 바이너리 파일)"""
|
|
||||||
# task_id 생성
|
|
||||||
task_id = await generate_task_id()
|
|
||||||
|
|
||||||
# 1. 진입 검증: images_json 또는 files 중 하나는 반드시 있어야 함
|
|
||||||
has_images_json = images_json is not None and images_json.strip() != ""
|
|
||||||
has_files = files is not None and len(files) > 0
|
|
||||||
|
|
||||||
if not has_images_json and not has_files:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="images_json 또는 files 중 하나는 반드시 제공해야 합니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. images_json 파싱 (있는 경우만)
|
|
||||||
url_images: list[ImageUrlItem] = []
|
|
||||||
if has_images_json:
|
|
||||||
try:
|
|
||||||
parsed = json.loads(images_json)
|
|
||||||
if isinstance(parsed, list):
|
|
||||||
url_images = [ImageUrlItem(**item) for item in parsed if item]
|
|
||||||
except (json.JSONDecodeError, TypeError, ValueError) as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"images_json 파싱 오류: {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3. 유효한 파일만 필터링 (빈 파일, 유효한 이미지 확장자가 아닌 경우 제외)
|
|
||||||
valid_files: list[UploadFile] = []
|
|
||||||
skipped_files: list[str] = []
|
|
||||||
if has_files and files:
|
|
||||||
for f in files:
|
|
||||||
is_valid_ext = _is_valid_image_extension(f.filename)
|
|
||||||
is_not_empty = (
|
|
||||||
f.size is None or f.size > 0
|
|
||||||
) # size가 None이면 아직 읽지 않은 것
|
|
||||||
is_real_file = (
|
|
||||||
f.filename and f.filename != "filename"
|
|
||||||
) # Swagger 빈 파일 체크
|
|
||||||
if f and is_real_file and is_valid_ext and is_not_empty:
|
|
||||||
valid_files.append(f)
|
|
||||||
else:
|
|
||||||
skipped_files.append(f.filename or "unknown")
|
|
||||||
|
|
||||||
# 유효한 데이터가 하나도 없으면 에러
|
|
||||||
if not url_images and not valid_files:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"유효한 이미지가 없습니다. 지원 확장자: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}. 건너뛴 파일: {skipped_files}",
|
|
||||||
)
|
|
||||||
|
|
||||||
result_images: list[ImageUploadResultItem] = []
|
|
||||||
img_order = 0
|
|
||||||
|
|
||||||
# 1. URL 이미지 저장
|
|
||||||
for url_item in url_images:
|
|
||||||
img_name = url_item.name or _extract_image_name(url_item.url, img_order)
|
|
||||||
|
|
||||||
image = Image(
|
|
||||||
task_id=task_id,
|
|
||||||
img_name=img_name,
|
|
||||||
img_url=url_item.url,
|
|
||||||
img_order=img_order,
|
|
||||||
)
|
|
||||||
session.add(image)
|
|
||||||
await session.flush() # ID 생성을 위해 flush
|
|
||||||
|
|
||||||
result_images.append(
|
|
||||||
ImageUploadResultItem(
|
|
||||||
id=image.id,
|
|
||||||
img_name=img_name,
|
|
||||||
img_url=url_item.url,
|
|
||||||
img_order=img_order,
|
|
||||||
source="url",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
img_order += 1
|
|
||||||
|
|
||||||
# 2. 바이너리 파일을 media에 저장
|
|
||||||
if valid_files:
|
|
||||||
today = date.today().strftime("%Y-%m-%d")
|
|
||||||
# 한 번의 요청에서 업로드된 모든 이미지는 같은 폴더에 저장
|
|
||||||
batch_uuid = await generate_task_id()
|
|
||||||
upload_dir = MEDIA_ROOT / "image" / today / batch_uuid
|
|
||||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
for file in valid_files:
|
|
||||||
# 파일명: 원본 파일명 사용 (중복 방지를 위해 순서 추가)
|
|
||||||
original_name = file.filename or "image"
|
|
||||||
ext = _get_file_extension(file.filename) # type: ignore[arg-type]
|
|
||||||
# 파일명에서 확장자 제거 후 순서 추가
|
|
||||||
name_without_ext = (
|
|
||||||
original_name.rsplit(".", 1)[0]
|
|
||||||
if "." in original_name
|
|
||||||
else original_name
|
|
||||||
)
|
|
||||||
filename = f"{name_without_ext}_{img_order:03d}{ext}"
|
|
||||||
|
|
||||||
save_path = upload_dir / filename
|
|
||||||
|
|
||||||
# media에 파일 저장
|
|
||||||
await _save_upload_file(file, save_path)
|
|
||||||
|
|
||||||
# media 기준 URL 생성
|
|
||||||
img_url = f"/media/image/{today}/{batch_uuid}/{filename}"
|
|
||||||
img_name = file.filename or filename
|
|
||||||
|
|
||||||
image = Image(
|
|
||||||
task_id=task_id,
|
|
||||||
img_name=img_name,
|
|
||||||
img_url=img_url, # Media URL을 DB에 저장
|
|
||||||
img_order=img_order,
|
|
||||||
)
|
|
||||||
session.add(image)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
result_images.append(
|
|
||||||
ImageUploadResultItem(
|
|
||||||
id=image.id,
|
|
||||||
img_name=img_name,
|
|
||||||
img_url=img_url,
|
|
||||||
img_order=img_order,
|
|
||||||
source="file",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
img_order += 1
|
|
||||||
|
|
||||||
saved_count = len(result_images)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
# Image 테이블에서 현재 task_id의 이미지 URL 목록 조회
|
|
||||||
image_urls = [img.img_url for img in result_images]
|
|
||||||
|
|
||||||
return ImageUploadResponse(
|
|
||||||
task_id=task_id,
|
|
||||||
total_count=len(result_images),
|
|
||||||
url_count=len(url_images),
|
|
||||||
file_count=len(valid_files),
|
|
||||||
saved_count=saved_count,
|
|
||||||
images=result_images,
|
|
||||||
image_urls=image_urls,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/image/upload/blob",
|
"/image/upload/blob",
|
||||||
summary="이미지 업로드 (Azure Blob Storage)",
|
summary="이미지 업로드 (Azure Blob Storage)",
|
||||||
|
|
@ -988,6 +750,10 @@ async def upload_images_blob(
|
||||||
saved_count = len(result_images)
|
saved_count = len(result_images)
|
||||||
image_urls = [img.img_url for img in result_images]
|
image_urls = [img.img_url for img in result_images]
|
||||||
|
|
||||||
|
logger.info(f"[image_tagging] START - task_id: {task_id}")
|
||||||
|
await tag_images_if_not_exist(image_urls)
|
||||||
|
logger.info(f"[image_tagging] Done - task_id: {task_id}")
|
||||||
|
|
||||||
total_time = time.perf_counter() - request_start
|
total_time = time.perf_counter() - request_start
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
|
f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
|
||||||
|
|
@ -1003,3 +769,36 @@ async def upload_images_blob(
|
||||||
images=result_images,
|
images=result_images,
|
||||||
image_urls=image_urls,
|
image_urls=image_urls,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def tag_images_if_not_exist(
|
||||||
|
image_urls : list[str]
|
||||||
|
) -> None:
|
||||||
|
# 1. 조회
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
stmt = (
|
||||||
|
select(ImageTag)
|
||||||
|
.where(ImageTag.img_url_hash.in_([func.crc32(url) for url in image_urls]))
|
||||||
|
.where(ImageTag.img_url.in_(image_urls))
|
||||||
|
)
|
||||||
|
image_tags_query_results = await session.execute(stmt)
|
||||||
|
image_tags = image_tags_query_results.scalars().all()
|
||||||
|
existing_urls = {tag.img_url for tag in image_tags}
|
||||||
|
new_tags = [
|
||||||
|
ImageTag(img_url=url, img_tag=None)
|
||||||
|
for url in image_urls
|
||||||
|
if url not in existing_urls
|
||||||
|
]
|
||||||
|
session.add_all(new_tags)
|
||||||
|
|
||||||
|
null_tags = [tag for tag in image_tags if tag.img_tag is None] + new_tags
|
||||||
|
|
||||||
|
if null_tags:
|
||||||
|
tag_datas = await autotag_images([img.img_url for img in null_tags])
|
||||||
|
|
||||||
|
print(tag_datas)
|
||||||
|
|
||||||
|
for tag, tag_data in zip(null_tags, tag_datas):
|
||||||
|
tag.img_tag = tag_data.model_dump(mode="json")
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ Home 모듈 SQLAlchemy 모델 정의
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, List, Optional, Any
|
from typing import TYPE_CHECKING, List, Optional, Any
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, JSON, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Computed, Index, Integer, String, Text, JSON, func
|
||||||
|
from sqlalchemy.dialects.mysql import INTEGER
|
||||||
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
|
||||||
|
|
@ -300,6 +301,12 @@ class MarketingIntel(Base):
|
||||||
comment="마케팅 인텔리전스 결과물",
|
comment="마케팅 인텔리전스 결과물",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
subtitle : Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
nullable=True,
|
||||||
|
comment="자막 정보 생성 결과물",
|
||||||
|
)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|
@ -308,13 +315,50 @@ class MarketingIntel(Base):
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
task_id_str = (
|
return (
|
||||||
(self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id
|
f"<MarketingIntel(id={self.id}, place_id='{self.place_id}')>"
|
||||||
)
|
|
||||||
img_name_str = (
|
|
||||||
(self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
|
||||||
f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
|
class ImageTag(Base):
|
||||||
|
"""
|
||||||
|
이미지 태그 테이블
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "image_tags"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_img_url_hash", "img_url_hash"), # CRC32 index
|
||||||
|
{
|
||||||
|
"mysql_engine": "InnoDB",
|
||||||
|
"mysql_charset": "utf8mb4",
|
||||||
|
"mysql_collate": "utf8mb4_unicode_ci",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
primary_key=True,
|
||||||
|
nullable=False,
|
||||||
|
autoincrement=True,
|
||||||
|
comment="고유 식별자",
|
||||||
|
)
|
||||||
|
|
||||||
|
img_url: Mapped[str] = mapped_column(
|
||||||
|
String(2048),
|
||||||
|
nullable=False,
|
||||||
|
comment="이미지 URL (blob, CDN 경로)",
|
||||||
|
)
|
||||||
|
|
||||||
|
img_url_hash: Mapped[int] = mapped_column(
|
||||||
|
INTEGER(unsigned=True),
|
||||||
|
Computed("CRC32(img_url)", persisted=True), # generated column
|
||||||
|
comment="URL CRC32 해시 (검색용 index)",
|
||||||
|
)
|
||||||
|
|
||||||
|
img_tag: Mapped[dict[str, Any]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
nullable=True,
|
||||||
|
default=False,
|
||||||
|
comment="태그 JSON",
|
||||||
)
|
)
|
||||||
|
|
@ -41,8 +41,8 @@ from app.lyric.schemas.lyric import (
|
||||||
LyricListItem,
|
LyricListItem,
|
||||||
LyricStatusResponse,
|
LyricStatusResponse,
|
||||||
)
|
)
|
||||||
from app.lyric.worker.lyric_task import generate_lyric_background
|
from app.lyric.worker.lyric_task import generate_lyric_background, generate_subtitle_background
|
||||||
from app.utils.chatgpt_prompt import ChatgptService
|
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.pagination import PaginatedResponse, get_paginated
|
from app.utils.pagination import PaginatedResponse, get_paginated
|
||||||
|
|
||||||
|
|
@ -253,17 +253,6 @@ async def generate_lyric(
|
||||||
step1_start = time.perf_counter()
|
step1_start = time.perf_counter()
|
||||||
logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
|
logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
|
||||||
|
|
||||||
# service = ChatgptService(
|
|
||||||
# customer_name=request_body.customer_name,
|
|
||||||
# region=request_body.region,
|
|
||||||
# detail_region_info=request_body.detail_region_info or "",
|
|
||||||
# language=request_body.language,
|
|
||||||
# )
|
|
||||||
|
|
||||||
# prompt = service.build_lyrics_prompt()
|
|
||||||
# 원래는 실제 사용할 프롬프트가 들어가야 하나, 로직이 변경되어 이 시점에서 이곳에서 프롬프트를 생성할 이유가 없어서 삭제됨.
|
|
||||||
# 기존 코드와의 호환을 위해 동일한 로직으로 프롬프트 생성
|
|
||||||
|
|
||||||
promotional_expressions = {
|
promotional_expressions = {
|
||||||
"Korean" : "인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소",
|
"Korean" : "인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소",
|
||||||
"English" : "Instagram vibes, picture-perfect day, healing, travel, getaway",
|
"English" : "Instagram vibes, picture-perfect day, healing, travel, getaway",
|
||||||
|
|
@ -351,7 +340,7 @@ async def generate_lyric(
|
||||||
# ========== Step 4: 백그라운드 태스크 스케줄링 ==========
|
# ========== Step 4: 백그라운드 태스크 스케줄링 ==========
|
||||||
step4_start = time.perf_counter()
|
step4_start = time.perf_counter()
|
||||||
logger.debug(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
|
logger.debug(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
|
||||||
|
orientation = request_body.orientation
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
generate_lyric_background,
|
generate_lyric_background,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
|
|
@ -360,6 +349,12 @@ async def generate_lyric(
|
||||||
lyric_id=lyric.id,
|
lyric_id=lyric.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
background_tasks.add_task(
|
||||||
|
generate_subtitle_background,
|
||||||
|
orientation = orientation,
|
||||||
|
task_id=task_id
|
||||||
|
)
|
||||||
|
|
||||||
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
step4_elapsed = (time.perf_counter() - step4_start) * 1000
|
||||||
logger.debug(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)")
|
logger.debug(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ Lyric API Schemas
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
@ -42,7 +42,8 @@ class GenerateLyricRequest(BaseModel):
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
"m_id" : 1
|
"m_id" : 2,
|
||||||
|
"orientation" : "vertical"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -54,7 +55,8 @@ class GenerateLyricRequest(BaseModel):
|
||||||
"region": "군산",
|
"region": "군산",
|
||||||
"detail_region_info": "군산 신흥동 말랭이 마을",
|
"detail_region_info": "군산 신흥동 말랭이 마을",
|
||||||
"language": "Korean",
|
"language": "Korean",
|
||||||
"m_id" : 1
|
"m_id" : 1,
|
||||||
|
"orientation" : "vertical"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -68,7 +70,11 @@ class GenerateLyricRequest(BaseModel):
|
||||||
language: str = Field(
|
language: str = Field(
|
||||||
default="Korean",
|
default="Korean",
|
||||||
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
)
|
),
|
||||||
|
orientation: Literal["horizontal", "vertical"] = Field(
|
||||||
|
default="vertical",
|
||||||
|
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
|
||||||
|
),
|
||||||
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
|
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,15 @@ Lyric Background Tasks
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from app.database.session import BackgroundSessionLocal
|
from app.database.session import BackgroundSessionLocal
|
||||||
|
from app.home.models import Image, Project, MarketingIntel
|
||||||
from app.lyric.models import Lyric
|
from app.lyric.models import Lyric
|
||||||
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
from app.utils.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
||||||
|
from app.utils.subtitles import SubtitleContentsGenerator
|
||||||
|
from app.utils.creatomate import CreatomateService
|
||||||
from app.utils.prompts.prompts import Prompt
|
from app.utils.prompts.prompts import Prompt
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
|
|
||||||
|
|
@ -100,13 +104,6 @@ async def generate_lyric_background(
|
||||||
step1_start = time.perf_counter()
|
step1_start = time.perf_counter()
|
||||||
logger.debug(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
|
logger.debug(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
|
||||||
|
|
||||||
# service = ChatgptService(
|
|
||||||
# customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
|
|
||||||
# region="",
|
|
||||||
# detail_region_info="",
|
|
||||||
# language=language,
|
|
||||||
# )
|
|
||||||
|
|
||||||
chatgpt = ChatgptService()
|
chatgpt = ChatgptService()
|
||||||
|
|
||||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
|
|
@ -158,3 +155,55 @@ async def generate_lyric_background(
|
||||||
elapsed = (time.perf_counter() - task_start) * 1000
|
elapsed = (time.perf_counter() - task_start) * 1000
|
||||||
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
|
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
|
||||||
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}", lyric_id)
|
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}", lyric_id)
|
||||||
|
|
||||||
|
async def generate_subtitle_background(
|
||||||
|
orientation: str,
|
||||||
|
task_id: str
|
||||||
|
) -> None:
|
||||||
|
logger.info(f"[generate_subtitle_background] task_id: {task_id}, {orientation}")
|
||||||
|
creatomate_service = CreatomateService(orientation=orientation)
|
||||||
|
template = await creatomate_service.get_one_template_data(creatomate_service.template_id)
|
||||||
|
pitchings = creatomate_service.extract_text_format_from_template(template)
|
||||||
|
|
||||||
|
subtitle_generator = SubtitleContentsGenerator()
|
||||||
|
|
||||||
|
async with BackgroundSessionLocal() as session:
|
||||||
|
project_result = await session.execute(
|
||||||
|
select(Project)
|
||||||
|
.where(Project.task_id == task_id)
|
||||||
|
.order_by(Project.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
project = project_result.scalar_one_or_none()
|
||||||
|
marketing_result = await session.execute(
|
||||||
|
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
||||||
|
)
|
||||||
|
marketing_intelligence = marketing_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
store_address = project.detail_region_info
|
||||||
|
customer_name = project.store_name
|
||||||
|
logger.info(f"[generate_subtitle_background] customer_name: {customer_name}, {store_address}")
|
||||||
|
|
||||||
|
generated_subtitles = await subtitle_generator.generate_subtitle_contents(
|
||||||
|
marketing_intelligence = marketing_intelligence.intel_result,
|
||||||
|
pitching_label_list = pitchings,
|
||||||
|
customer_name = customer_name,
|
||||||
|
detail_region_info = store_address,
|
||||||
|
)
|
||||||
|
pitching_output_list = generated_subtitles.pitching_results
|
||||||
|
|
||||||
|
subtitle_modifications = {pitching_output.pitching_tag : pitching_output.pitching_data for pitching_output in pitching_output_list}
|
||||||
|
logger.info(f"[generate_subtitle_background] subtitle_modifications: {subtitle_modifications}")
|
||||||
|
|
||||||
|
async with BackgroundSessionLocal() as session:
|
||||||
|
marketing_result = await session.execute(
|
||||||
|
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
||||||
|
)
|
||||||
|
marketing_intelligence = marketing_result.scalar_one_or_none()
|
||||||
|
marketing_intelligence.subtitle = subtitle_modifications
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"[generate_subtitle_background] task_id: {task_id} DONE")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,7 @@ async def get_account_by_platform(
|
||||||
|
|
||||||
raise SocialAccountNotFoundError(platform=platform.value)
|
raise SocialAccountNotFoundError(platform=platform.value)
|
||||||
|
|
||||||
return social_account_service._to_response(account)
|
return social_account_service.to_response(account)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
|
|
|
||||||
|
|
@ -1,131 +1,37 @@
|
||||||
|
"""
|
||||||
|
소셜 SEO API 라우터
|
||||||
|
|
||||||
import logging, json
|
SEO 관련 엔드포인트를 제공합니다.
|
||||||
|
비즈니스 로직은 SeoService에 위임합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
from redis.asyncio import Redis
|
import logging
|
||||||
|
|
||||||
from config import social_oauth_settings, db_settings
|
from fastapi import APIRouter, Depends
|
||||||
from app.social.constants import YOUTUBE_SEO_HASH
|
|
||||||
from sqlalchemy import select, func
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.social.schemas import (
|
|
||||||
YoutubeDescriptionRequest,
|
|
||||||
YoutubeDescriptionResponse,
|
|
||||||
)
|
|
||||||
|
|
||||||
from app.database.session import get_session
|
from app.database.session import get_session
|
||||||
|
from app.social.schemas import YoutubeDescriptionRequest, YoutubeDescriptionResponse
|
||||||
|
from app.social.services import seo_service
|
||||||
from app.user.dependencies import get_current_user
|
from app.user.dependencies import get_current_user
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.home.models import Project, MarketingIntel
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
from app.utils.prompts.prompts import yt_upload_prompt
|
|
||||||
from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
|
||||||
|
|
||||||
redis_seo_client = Redis(
|
|
||||||
host=db_settings.REDIS_HOST,
|
|
||||||
port=db_settings.REDIS_PORT,
|
|
||||||
db=0,
|
|
||||||
decode_responses=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/seo", tags=["Social SEO"])
|
router = APIRouter(prefix="/seo", tags=["Social SEO"])
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/youtube",
|
"/youtube",
|
||||||
response_model=YoutubeDescriptionResponse,
|
response_model=YoutubeDescriptionResponse,
|
||||||
summary="유튜브 SEO descrption 생성",
|
summary="유튜브 SEO description 생성",
|
||||||
description="유튜브 업로드 시 사용할 descrption을 SEO 적용하여 생성",
|
description="유튜브 업로드 시 사용할 description을 SEO 적용하여 생성",
|
||||||
)
|
)
|
||||||
async def youtube_seo_description(
|
async def youtube_seo_description(
|
||||||
request_body: YoutubeDescriptionRequest,
|
request_body: YoutubeDescriptionRequest,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> YoutubeDescriptionResponse:
|
) -> YoutubeDescriptionResponse:
|
||||||
|
return await seo_service.get_youtube_seo_description(
|
||||||
# TODO : 나중에 Session Task_id 검증 미들웨어 만들면 추가해주세요.
|
request_body.task_id, current_user, session
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[youtube_seo_description] Try Cache - user: {current_user.user_uuid} / task_id : {request_body.task_id}"
|
|
||||||
)
|
)
|
||||||
cached = await get_yt_seo_in_redis(request_body.task_id)
|
|
||||||
if cached: # redis hit
|
|
||||||
return cached
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[youtube_seo_description] Cache miss - user: {current_user.user_uuid} "
|
|
||||||
)
|
|
||||||
updated_seo = await make_youtube_seo_description(request_body.task_id, current_user, session)
|
|
||||||
await set_yt_seo_in_redis(request_body.task_id, updated_seo)
|
|
||||||
|
|
||||||
return updated_seo
|
|
||||||
|
|
||||||
async def make_youtube_seo_description(
|
|
||||||
task_id: str,
|
|
||||||
current_user: User,
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> YoutubeDescriptionResponse:
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[make_youtube_seo_description] START - user: {current_user.user_uuid} "
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
project_query = await session.execute(
|
|
||||||
select(Project)
|
|
||||||
.where(
|
|
||||||
Project.task_id == task_id,
|
|
||||||
Project.user_uuid == current_user.user_uuid)
|
|
||||||
.order_by(Project.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
|
|
||||||
project = project_query.scalar_one_or_none()
|
|
||||||
marketing_query = await session.execute(
|
|
||||||
select(MarketingIntel)
|
|
||||||
.where(MarketingIntel.id == project.marketing_intelligence)
|
|
||||||
)
|
|
||||||
marketing_intelligence = marketing_query.scalar_one_or_none()
|
|
||||||
|
|
||||||
hashtags = marketing_intelligence.intel_result["target_keywords"]
|
|
||||||
|
|
||||||
yt_seo_input_data = {
|
|
||||||
"customer_name" : project.store_name,
|
|
||||||
"detail_region_info" : project.detail_region_info,
|
|
||||||
"marketing_intelligence_summary" : json.dumps(marketing_intelligence.intel_result, ensure_ascii=False),
|
|
||||||
"language" : project.language,
|
|
||||||
"target_keywords" : hashtags
|
|
||||||
}
|
|
||||||
chatgpt = ChatgptService(timeout = 180)
|
|
||||||
yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data)
|
|
||||||
result_dict = {
|
|
||||||
"title" : yt_seo_output.title,
|
|
||||||
"description" : yt_seo_output.description,
|
|
||||||
"keywords": hashtags
|
|
||||||
}
|
|
||||||
|
|
||||||
result = YoutubeDescriptionResponse(**result_dict)
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[youtube_seo_description] EXCEPTION - error: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_yt_seo_in_redis(task_id:str) -> YoutubeDescriptionResponse | None:
|
|
||||||
field = f"task_id:{task_id}"
|
|
||||||
yt_seo_info = await redis_seo_client.hget(YOUTUBE_SEO_HASH, field)
|
|
||||||
if yt_seo_info:
|
|
||||||
yt_seo = json.loads(yt_seo_info)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
return YoutubeDescriptionResponse(**yt_seo)
|
|
||||||
|
|
||||||
async def set_yt_seo_in_redis(task_id:str, yt_seo : YoutubeDescriptionResponse) -> None:
|
|
||||||
field = f"task_id:{task_id}"
|
|
||||||
yt_seo_info = json.dumps(yt_seo.model_dump(), ensure_ascii=False)
|
|
||||||
await redis_seo_client.hsetex(YOUTUBE_SEO_HASH, field, yt_seo_info, ex=3600)
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,39 +2,34 @@
|
||||||
소셜 업로드 API 라우터
|
소셜 업로드 API 라우터
|
||||||
|
|
||||||
소셜 미디어 영상 업로드 관련 엔드포인트를 제공합니다.
|
소셜 미디어 영상 업로드 관련 엔드포인트를 제공합니다.
|
||||||
|
비즈니스 로직은 SocialUploadService에 위임합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
||||||
from fastapi import HTTPException, status
|
|
||||||
from sqlalchemy import select, func
|
|
||||||
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.social.constants import SocialPlatform, UploadStatus
|
from app.social.constants import SocialPlatform
|
||||||
from app.social.exceptions import SocialAccountNotFoundError, VideoNotFoundError
|
|
||||||
from app.social.models import SocialUpload
|
|
||||||
from app.social.schemas import (
|
from app.social.schemas import (
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
SocialUploadHistoryItem,
|
|
||||||
SocialUploadHistoryResponse,
|
SocialUploadHistoryResponse,
|
||||||
SocialUploadRequest,
|
SocialUploadRequest,
|
||||||
SocialUploadResponse,
|
SocialUploadResponse,
|
||||||
SocialUploadStatusResponse,
|
SocialUploadStatusResponse,
|
||||||
)
|
)
|
||||||
from app.social.services import social_account_service
|
from app.social.services import SocialUploadService, social_account_service
|
||||||
from app.social.worker.upload_task import process_social_upload
|
|
||||||
from app.user.dependencies import get_current_user
|
from app.user.dependencies import get_current_user
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.video.models import Video
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/upload", tags=["Social Upload"])
|
router = APIRouter(prefix="/upload", tags=["Social Upload"])
|
||||||
|
|
||||||
|
upload_service = SocialUploadService(account_service=social_account_service)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"",
|
"",
|
||||||
|
|
@ -71,126 +66,7 @@ async def upload_to_social(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> SocialUploadResponse:
|
) -> SocialUploadResponse:
|
||||||
"""
|
return await upload_service.request_upload(body, current_user, session, background_tasks)
|
||||||
소셜 플랫폼에 영상 업로드 요청
|
|
||||||
|
|
||||||
백그라운드에서 영상을 다운로드하고 소셜 플랫폼에 업로드합니다.
|
|
||||||
"""
|
|
||||||
logger.info(
|
|
||||||
f"[UPLOAD_API] 업로드 요청 - "
|
|
||||||
f"user_uuid: {current_user.user_uuid}, "
|
|
||||||
f"video_id: {body.video_id}, "
|
|
||||||
f"social_account_id: {body.social_account_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 1. 영상 조회 및 검증
|
|
||||||
video_result = await session.execute(
|
|
||||||
select(Video).where(Video.id == body.video_id)
|
|
||||||
)
|
|
||||||
video = video_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not video:
|
|
||||||
logger.warning(f"[UPLOAD_API] 영상 없음 - video_id: {body.video_id}")
|
|
||||||
raise VideoNotFoundError(video_id=body.video_id)
|
|
||||||
|
|
||||||
if not video.result_movie_url:
|
|
||||||
logger.warning(f"[UPLOAD_API] 영상 URL 없음 - video_id: {body.video_id}")
|
|
||||||
raise VideoNotFoundError(
|
|
||||||
video_id=body.video_id,
|
|
||||||
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. 소셜 계정 조회 (social_account_id로 직접 조회, 소유권 검증 포함)
|
|
||||||
account = await social_account_service.get_account_by_id(
|
|
||||||
user_uuid=current_user.user_uuid,
|
|
||||||
account_id=body.social_account_id,
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not account:
|
|
||||||
logger.warning(
|
|
||||||
f"[UPLOAD_API] 연동 계정 없음 - "
|
|
||||||
f"user_uuid: {current_user.user_uuid}, social_account_id: {body.social_account_id}"
|
|
||||||
)
|
|
||||||
raise SocialAccountNotFoundError()
|
|
||||||
|
|
||||||
# 3. 진행 중인 업로드 확인 (pending 또는 uploading 상태만)
|
|
||||||
in_progress_result = await session.execute(
|
|
||||||
select(SocialUpload).where(
|
|
||||||
SocialUpload.video_id == body.video_id,
|
|
||||||
SocialUpload.social_account_id == account.id,
|
|
||||||
SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
in_progress_upload = in_progress_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if in_progress_upload:
|
|
||||||
logger.info(
|
|
||||||
f"[UPLOAD_API] 진행 중인 업로드 존재 - upload_id: {in_progress_upload.id}"
|
|
||||||
)
|
|
||||||
return SocialUploadResponse(
|
|
||||||
success=True,
|
|
||||||
upload_id=in_progress_upload.id,
|
|
||||||
platform=account.platform,
|
|
||||||
status=in_progress_upload.status,
|
|
||||||
message="이미 업로드가 진행 중입니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. 업로드 순번 계산 (동일 video + account 조합에서 최대 순번 + 1)
|
|
||||||
max_seq_result = await session.execute(
|
|
||||||
select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where(
|
|
||||||
SocialUpload.video_id == body.video_id,
|
|
||||||
SocialUpload.social_account_id == account.id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
max_seq = max_seq_result.scalar() or 0
|
|
||||||
next_seq = max_seq + 1
|
|
||||||
|
|
||||||
# 5. 새 업로드 레코드 생성 (항상 새로 생성하여 이력 보존)
|
|
||||||
social_upload = SocialUpload(
|
|
||||||
user_uuid=current_user.user_uuid,
|
|
||||||
video_id=body.video_id,
|
|
||||||
social_account_id=account.id,
|
|
||||||
upload_seq=next_seq,
|
|
||||||
platform=account.platform,
|
|
||||||
status=UploadStatus.PENDING.value,
|
|
||||||
upload_progress=0,
|
|
||||||
title=body.title,
|
|
||||||
description=body.description,
|
|
||||||
tags=body.tags,
|
|
||||||
privacy_status=body.privacy_status.value,
|
|
||||||
scheduled_at=body.scheduled_at,
|
|
||||||
platform_options={
|
|
||||||
**(body.platform_options or {}),
|
|
||||||
"scheduled_at": body.scheduled_at.isoformat() if body.scheduled_at else None,
|
|
||||||
},
|
|
||||||
retry_count=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
session.add(social_upload)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(social_upload)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[UPLOAD_API] 업로드 레코드 생성 - "
|
|
||||||
f"upload_id: {social_upload.id}, video_id: {body.video_id}, "
|
|
||||||
f"account_id: {account.id}, upload_seq: {next_seq}, platform: {account.platform}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 6. 백그라운드 태스크 등록 (즉시 업로드 or 예약 없을 때만)
|
|
||||||
from app.utils.timezone import now as utcnow
|
|
||||||
is_scheduled = body.scheduled_at and body.scheduled_at > utcnow().replace(tzinfo=None)
|
|
||||||
if not is_scheduled:
|
|
||||||
background_tasks.add_task(process_social_upload, social_upload.id)
|
|
||||||
|
|
||||||
message = "예약 업로드가 등록되었습니다." if is_scheduled else "업로드 요청이 접수되었습니다."
|
|
||||||
return SocialUploadResponse(
|
|
||||||
success=True,
|
|
||||||
upload_id=social_upload.id,
|
|
||||||
platform=account.platform,
|
|
||||||
status=social_upload.status,
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|
@ -204,43 +80,7 @@ async def get_upload_status(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> SocialUploadStatusResponse:
|
) -> SocialUploadStatusResponse:
|
||||||
"""
|
return await upload_service.get_upload_status(upload_id, current_user, session)
|
||||||
업로드 상태 조회
|
|
||||||
"""
|
|
||||||
logger.info(f"[UPLOAD_API] 상태 조회 - upload_id: {upload_id}")
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialUpload).where(
|
|
||||||
SocialUpload.id == upload_id,
|
|
||||||
SocialUpload.user_uuid == current_user.user_uuid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
upload = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not upload:
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="업로드 정보를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
return SocialUploadStatusResponse(
|
|
||||||
upload_id=upload.id,
|
|
||||||
video_id=upload.video_id,
|
|
||||||
social_account_id=upload.social_account_id,
|
|
||||||
upload_seq=upload.upload_seq,
|
|
||||||
platform=upload.platform,
|
|
||||||
status=UploadStatus(upload.status),
|
|
||||||
upload_progress=upload.upload_progress,
|
|
||||||
title=upload.title,
|
|
||||||
platform_video_id=upload.platform_video_id,
|
|
||||||
platform_url=upload.platform_url,
|
|
||||||
error_message=upload.error_message,
|
|
||||||
retry_count=upload.retry_count,
|
|
||||||
created_at=upload.created_at,
|
|
||||||
uploaded_at=upload.uploaded_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|
@ -267,98 +107,8 @@ async def get_upload_history(
|
||||||
page: int = Query(1, ge=1, description="페이지 번호"),
|
page: int = Query(1, ge=1, description="페이지 번호"),
|
||||||
size: int = Query(20, ge=1, le=100, description="페이지 크기"),
|
size: int = Query(20, ge=1, le=100, description="페이지 크기"),
|
||||||
) -> SocialUploadHistoryResponse:
|
) -> SocialUploadHistoryResponse:
|
||||||
"""
|
return await upload_service.get_upload_history(
|
||||||
업로드 이력 조회
|
current_user, session, tab, platform, year, month, page, size
|
||||||
"""
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
target_year = year or now.year
|
|
||||||
target_month = month or now.month
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[UPLOAD_API] 이력 조회 - "
|
|
||||||
f"user_uuid: {current_user.user_uuid}, tab: {tab}, "
|
|
||||||
f"year: {target_year}, month: {target_month}, page: {page}, size: {size}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 월 범위 계산
|
|
||||||
from calendar import monthrange
|
|
||||||
last_day = monthrange(target_year, target_month)[1]
|
|
||||||
month_start = datetime(target_year, target_month, 1, 0, 0, 0)
|
|
||||||
month_end = datetime(target_year, target_month, last_day, 23, 59, 59)
|
|
||||||
|
|
||||||
# 기본 쿼리 (cancelled 제외)
|
|
||||||
query = select(SocialUpload).where(
|
|
||||||
SocialUpload.user_uuid == current_user.user_uuid,
|
|
||||||
SocialUpload.created_at >= month_start,
|
|
||||||
SocialUpload.created_at <= month_end,
|
|
||||||
SocialUpload.status != UploadStatus.CANCELLED.value,
|
|
||||||
)
|
|
||||||
count_query = select(func.count(SocialUpload.id)).where(
|
|
||||||
SocialUpload.user_uuid == current_user.user_uuid,
|
|
||||||
SocialUpload.created_at >= month_start,
|
|
||||||
SocialUpload.created_at <= month_end,
|
|
||||||
SocialUpload.status != UploadStatus.CANCELLED.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 탭 필터 적용
|
|
||||||
if tab == "completed":
|
|
||||||
query = query.where(SocialUpload.status == UploadStatus.COMPLETED.value)
|
|
||||||
count_query = count_query.where(SocialUpload.status == UploadStatus.COMPLETED.value)
|
|
||||||
elif tab == "scheduled":
|
|
||||||
query = query.where(
|
|
||||||
SocialUpload.status == UploadStatus.PENDING.value,
|
|
||||||
SocialUpload.scheduled_at.isnot(None),
|
|
||||||
)
|
|
||||||
count_query = count_query.where(
|
|
||||||
SocialUpload.status == UploadStatus.PENDING.value,
|
|
||||||
SocialUpload.scheduled_at.isnot(None),
|
|
||||||
)
|
|
||||||
elif tab == "failed":
|
|
||||||
query = query.where(SocialUpload.status == UploadStatus.FAILED.value)
|
|
||||||
count_query = count_query.where(SocialUpload.status == UploadStatus.FAILED.value)
|
|
||||||
|
|
||||||
# 플랫폼 필터 적용
|
|
||||||
if platform:
|
|
||||||
query = query.where(SocialUpload.platform == platform.value)
|
|
||||||
count_query = count_query.where(SocialUpload.platform == platform.value)
|
|
||||||
|
|
||||||
# 총 개수 조회
|
|
||||||
total_result = await session.execute(count_query)
|
|
||||||
total = total_result.scalar() or 0
|
|
||||||
|
|
||||||
# 페이지네이션 적용
|
|
||||||
query = (
|
|
||||||
query.order_by(SocialUpload.created_at.desc())
|
|
||||||
.offset((page - 1) * size)
|
|
||||||
.limit(size)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await session.execute(query)
|
|
||||||
uploads = result.scalars().all()
|
|
||||||
|
|
||||||
items = [
|
|
||||||
SocialUploadHistoryItem(
|
|
||||||
upload_id=upload.id,
|
|
||||||
video_id=upload.video_id,
|
|
||||||
social_account_id=upload.social_account_id,
|
|
||||||
upload_seq=upload.upload_seq,
|
|
||||||
platform=upload.platform,
|
|
||||||
status=upload.status,
|
|
||||||
title=upload.title,
|
|
||||||
platform_url=upload.platform_url,
|
|
||||||
error_message=upload.error_message,
|
|
||||||
scheduled_at=upload.scheduled_at,
|
|
||||||
created_at=upload.created_at,
|
|
||||||
uploaded_at=upload.uploaded_at,
|
|
||||||
)
|
|
||||||
for upload in uploads
|
|
||||||
]
|
|
||||||
|
|
||||||
return SocialUploadHistoryResponse(
|
|
||||||
items=items,
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
size=size,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -374,53 +124,7 @@ async def retry_upload(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> SocialUploadResponse:
|
) -> SocialUploadResponse:
|
||||||
"""
|
return await upload_service.retry_upload(upload_id, current_user, session, background_tasks)
|
||||||
업로드 재시도
|
|
||||||
|
|
||||||
실패한 업로드를 다시 시도합니다.
|
|
||||||
"""
|
|
||||||
logger.info(f"[UPLOAD_API] 재시도 요청 - upload_id: {upload_id}")
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialUpload).where(
|
|
||||||
SocialUpload.id == upload_id,
|
|
||||||
SocialUpload.user_uuid == current_user.user_uuid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
upload = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not upload:
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="업로드 정보를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if upload.status not in [UploadStatus.FAILED.value, UploadStatus.CANCELLED.value]:
|
|
||||||
from fastapi import HTTPException, status
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="실패하거나 취소된 업로드만 재시도할 수 있습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 상태 초기화
|
|
||||||
upload.status = UploadStatus.PENDING.value
|
|
||||||
upload.upload_progress = 0
|
|
||||||
upload.error_message = None
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
# 백그라운드 태스크 등록
|
|
||||||
background_tasks.add_task(process_social_upload, upload.id)
|
|
||||||
|
|
||||||
return SocialUploadResponse(
|
|
||||||
success=True,
|
|
||||||
upload_id=upload.id,
|
|
||||||
platform=upload.platform,
|
|
||||||
status=upload.status,
|
|
||||||
message="업로드 재시도가 요청되었습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
|
|
@ -434,38 +138,4 @@ async def cancel_upload(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> MessageResponse:
|
) -> MessageResponse:
|
||||||
"""
|
return await upload_service.cancel_upload(upload_id, current_user, session)
|
||||||
업로드 취소
|
|
||||||
|
|
||||||
대기 중인 업로드를 취소합니다.
|
|
||||||
이미 진행 중이거나 완료된 업로드는 취소할 수 없습니다.
|
|
||||||
"""
|
|
||||||
logger.info(f"[UPLOAD_API] 취소 요청 - upload_id: {upload_id}")
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(SocialUpload).where(
|
|
||||||
SocialUpload.id == upload_id,
|
|
||||||
SocialUpload.user_uuid == current_user.user_uuid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
upload = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not upload:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="업로드 정보를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if upload.status != UploadStatus.PENDING.value:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="대기 중인 업로드만 취소할 수 있습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
upload.status = UploadStatus.CANCELLED.value
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return MessageResponse(
|
|
||||||
success=True,
|
|
||||||
message="업로드가 취소되었습니다.",
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ PLATFORM_CONFIG = {
|
||||||
YOUTUBE_SCOPES = [
|
YOUTUBE_SCOPES = [
|
||||||
"https://www.googleapis.com/auth/youtube.upload", # 영상 업로드
|
"https://www.googleapis.com/auth/youtube.upload", # 영상 업로드
|
||||||
"https://www.googleapis.com/auth/youtube.readonly", # 채널 정보 읽기
|
"https://www.googleapis.com/auth/youtube.readonly", # 채널 정보 읽기
|
||||||
|
"https://www.googleapis.com/auth/yt-analytics.readonly", # 대시보드
|
||||||
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
|
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
from app.social.schemas.oauth_schema import (
|
||||||
|
SocialConnectResponse,
|
||||||
|
SocialAccountResponse,
|
||||||
|
SocialAccountListResponse,
|
||||||
|
OAuthTokenResponse,
|
||||||
|
PlatformUserInfo,
|
||||||
|
MessageResponse,
|
||||||
|
)
|
||||||
|
from app.social.schemas.upload_schema import (
|
||||||
|
SocialUploadRequest,
|
||||||
|
SocialUploadResponse,
|
||||||
|
SocialUploadStatusResponse,
|
||||||
|
SocialUploadHistoryItem,
|
||||||
|
SocialUploadHistoryResponse,
|
||||||
|
)
|
||||||
|
from app.social.schemas.seo_schema import (
|
||||||
|
YoutubeDescriptionRequest,
|
||||||
|
YoutubeDescriptionResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SocialConnectResponse",
|
||||||
|
"SocialAccountResponse",
|
||||||
|
"SocialAccountListResponse",
|
||||||
|
"OAuthTokenResponse",
|
||||||
|
"PlatformUserInfo",
|
||||||
|
"MessageResponse",
|
||||||
|
"SocialUploadRequest",
|
||||||
|
"SocialUploadResponse",
|
||||||
|
"SocialUploadStatusResponse",
|
||||||
|
"SocialUploadHistoryItem",
|
||||||
|
"SocialUploadHistoryResponse",
|
||||||
|
"YoutubeDescriptionRequest",
|
||||||
|
"YoutubeDescriptionResponse",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
"""
|
||||||
|
소셜 OAuth 관련 Pydantic 스키마
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class SocialConnectResponse(BaseModel):
|
||||||
|
"""소셜 계정 연동 시작 응답"""
|
||||||
|
|
||||||
|
auth_url: str = Field(..., description="OAuth 인증 URL")
|
||||||
|
state: str = Field(..., description="CSRF 방지용 state 토큰")
|
||||||
|
platform: str = Field(..., description="플랫폼명")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
|
||||||
|
"state": "abc123xyz",
|
||||||
|
"platform": "youtube",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SocialAccountResponse(BaseModel):
|
||||||
|
"""연동된 소셜 계정 정보"""
|
||||||
|
|
||||||
|
id: int = Field(..., description="소셜 계정 ID")
|
||||||
|
platform: str = Field(..., description="플랫폼명")
|
||||||
|
platform_user_id: str = Field(..., description="플랫폼 내 사용자 ID")
|
||||||
|
platform_username: Optional[str] = Field(None, description="플랫폼 내 사용자명")
|
||||||
|
display_name: Optional[str] = Field(None, description="표시 이름")
|
||||||
|
profile_image_url: Optional[str] = Field(None, description="프로필 이미지 URL")
|
||||||
|
is_active: bool = Field(..., description="활성화 상태")
|
||||||
|
connected_at: datetime = Field(..., description="연동 일시")
|
||||||
|
platform_data: Optional[dict[str, Any]] = Field(
|
||||||
|
None, description="플랫폼별 추가 정보 (채널ID, 구독자 수 등)"
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
from_attributes=True,
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"id": 1,
|
||||||
|
"platform": "youtube",
|
||||||
|
"platform_user_id": "UC1234567890",
|
||||||
|
"platform_username": "my_channel",
|
||||||
|
"display_name": "My Channel",
|
||||||
|
"profile_image_url": "https://...",
|
||||||
|
"is_active": True,
|
||||||
|
"connected_at": "2024-01-15T12:00:00",
|
||||||
|
"platform_data": {
|
||||||
|
"channel_id": "UC1234567890",
|
||||||
|
"channel_title": "My Channel",
|
||||||
|
"subscriber_count": 1000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SocialAccountListResponse(BaseModel):
|
||||||
|
"""연동된 소셜 계정 목록 응답"""
|
||||||
|
|
||||||
|
accounts: list[SocialAccountResponse] = Field(..., description="연동 계정 목록")
|
||||||
|
total: int = Field(..., description="총 연동 계정 수")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"accounts": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"platform": "youtube",
|
||||||
|
"platform_user_id": "UC1234567890",
|
||||||
|
"platform_username": "my_channel",
|
||||||
|
"display_name": "My Channel",
|
||||||
|
"is_active": True,
|
||||||
|
"connected_at": "2024-01-15T12:00:00",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthTokenResponse(BaseModel):
|
||||||
|
"""OAuth 토큰 응답 (내부 사용)"""
|
||||||
|
|
||||||
|
access_token: str
|
||||||
|
refresh_token: Optional[str] = None
|
||||||
|
expires_in: int
|
||||||
|
token_type: str = "Bearer"
|
||||||
|
scope: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformUserInfo(BaseModel):
|
||||||
|
"""플랫폼 사용자 정보 (내부 사용)"""
|
||||||
|
|
||||||
|
platform_user_id: str
|
||||||
|
username: Optional[str] = None
|
||||||
|
display_name: Optional[str] = None
|
||||||
|
profile_image_url: Optional[str] = None
|
||||||
|
platform_data: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageResponse(BaseModel):
|
||||||
|
"""단순 메시지 응답"""
|
||||||
|
|
||||||
|
success: bool = Field(..., description="성공 여부")
|
||||||
|
message: str = Field(..., description="응답 메시지")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"success": True,
|
||||||
|
"message": "작업이 완료되었습니다.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""
|
||||||
|
소셜 SEO 관련 Pydantic 스키마
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class YoutubeDescriptionRequest(BaseModel):
|
||||||
|
"""유튜브 SEO Description 제안 요청"""
|
||||||
|
|
||||||
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"task_id": "019c739f-65fc-7d15-8c88-b31be00e588e"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class YoutubeDescriptionResponse(BaseModel):
|
||||||
|
"""유튜브 SEO Description 제안 응답"""
|
||||||
|
|
||||||
|
title: str = Field(..., description="유튜브 영상 제목 - SEO/AEO 최적화")
|
||||||
|
description: str = Field(..., description="제안된 유튜브 SEO Description")
|
||||||
|
keywords: list[str] = Field(..., description="해시태그 리스트")
|
||||||
|
|
||||||
|
model_config = ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
"example": {
|
||||||
|
"title": "여기에 더미 타이틀",
|
||||||
|
"description": "여기에 더미 텍스트",
|
||||||
|
"keywords": ["여기에", "더미", "해시태그"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
"""
|
"""
|
||||||
Social Media Schemas
|
소셜 업로드 관련 Pydantic 스키마
|
||||||
|
|
||||||
소셜 미디어 연동 관련 Pydantic 스키마를 정의합니다.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
@ -9,123 +7,7 @@ from typing import Any, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from app.social.constants import PrivacyStatus, SocialPlatform, UploadStatus
|
from app.social.constants import PrivacyStatus, UploadStatus
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# OAuth 관련 스키마
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class SocialConnectResponse(BaseModel):
|
|
||||||
"""소셜 계정 연동 시작 응답"""
|
|
||||||
|
|
||||||
auth_url: str = Field(..., description="OAuth 인증 URL")
|
|
||||||
state: str = Field(..., description="CSRF 방지용 state 토큰")
|
|
||||||
platform: str = Field(..., description="플랫폼명")
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
|
|
||||||
"state": "abc123xyz",
|
|
||||||
"platform": "youtube",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SocialAccountResponse(BaseModel):
|
|
||||||
"""연동된 소셜 계정 정보"""
|
|
||||||
|
|
||||||
id: int = Field(..., description="소셜 계정 ID")
|
|
||||||
platform: str = Field(..., description="플랫폼명")
|
|
||||||
platform_user_id: str = Field(..., description="플랫폼 내 사용자 ID")
|
|
||||||
platform_username: Optional[str] = Field(None, description="플랫폼 내 사용자명")
|
|
||||||
display_name: Optional[str] = Field(None, description="표시 이름")
|
|
||||||
profile_image_url: Optional[str] = Field(None, description="프로필 이미지 URL")
|
|
||||||
is_active: bool = Field(..., description="활성화 상태")
|
|
||||||
connected_at: datetime = Field(..., description="연동 일시")
|
|
||||||
platform_data: Optional[dict[str, Any]] = Field(
|
|
||||||
None, description="플랫폼별 추가 정보 (채널ID, 구독자 수 등)"
|
|
||||||
)
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
from_attributes=True,
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"id": 1,
|
|
||||||
"platform": "youtube",
|
|
||||||
"platform_user_id": "UC1234567890",
|
|
||||||
"platform_username": "my_channel",
|
|
||||||
"display_name": "My Channel",
|
|
||||||
"profile_image_url": "https://...",
|
|
||||||
"is_active": True,
|
|
||||||
"connected_at": "2024-01-15T12:00:00",
|
|
||||||
"platform_data": {
|
|
||||||
"channel_id": "UC1234567890",
|
|
||||||
"channel_title": "My Channel",
|
|
||||||
"subscriber_count": 1000,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SocialAccountListResponse(BaseModel):
|
|
||||||
"""연동된 소셜 계정 목록 응답"""
|
|
||||||
|
|
||||||
accounts: list[SocialAccountResponse] = Field(..., description="연동 계정 목록")
|
|
||||||
total: int = Field(..., description="총 연동 계정 수")
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"accounts": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"platform": "youtube",
|
|
||||||
"platform_user_id": "UC1234567890",
|
|
||||||
"platform_username": "my_channel",
|
|
||||||
"display_name": "My Channel",
|
|
||||||
"is_active": True,
|
|
||||||
"connected_at": "2024-01-15T12:00:00",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 내부 사용 스키마 (OAuth 토큰 응답)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class OAuthTokenResponse(BaseModel):
|
|
||||||
"""OAuth 토큰 응답 (내부 사용)"""
|
|
||||||
|
|
||||||
access_token: str
|
|
||||||
refresh_token: Optional[str] = None
|
|
||||||
expires_in: int
|
|
||||||
token_type: str = "Bearer"
|
|
||||||
scope: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class PlatformUserInfo(BaseModel):
|
|
||||||
"""플랫폼 사용자 정보 (내부 사용)"""
|
|
||||||
|
|
||||||
platform_user_id: str
|
|
||||||
username: Optional[str] = None
|
|
||||||
display_name: Optional[str] = None
|
|
||||||
profile_image_url: Optional[str] = None
|
|
||||||
platform_data: dict[str, Any] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 업로드 관련 스키마
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class SocialUploadRequest(BaseModel):
|
class SocialUploadRequest(BaseModel):
|
||||||
|
|
@ -159,7 +41,7 @@ class SocialUploadRequest(BaseModel):
|
||||||
"privacy_status": "public",
|
"privacy_status": "public",
|
||||||
"scheduled_at": "2026-02-02T15:00:00",
|
"scheduled_at": "2026-02-02T15:00:00",
|
||||||
"platform_options": {
|
"platform_options": {
|
||||||
"category_id": "22", # YouTube 카테고리
|
"category_id": "22",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -278,50 +160,3 @@ class SocialUploadHistoryResponse(BaseModel):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
class YoutubeDescriptionRequest(BaseModel):
|
|
||||||
"""유튜브 SEO Description 제안 (자동완성) Request 모델"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"task_id" : "019c739f-65fc-7d15-8c88-b31be00e588e"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
|
||||||
|
|
||||||
class YoutubeDescriptionResponse(BaseModel):
|
|
||||||
"""유튜브 SEO Description 제안 (자동완성) Response 모델"""
|
|
||||||
title:str = Field(..., description="유튜브 영상 제목 - SEO/AEO 최적화")
|
|
||||||
description : str = Field(..., description="제안된 유튜브 SEO Description")
|
|
||||||
keywords : list[str] = Field(..., description="해시태그 리스트")
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"title" : "여기에 더미 타이틀",
|
|
||||||
"description": "여기에 더미 텍스트",
|
|
||||||
"keywords": ["여기에", "더미", "해시태그"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# 공통 응답 스키마
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class MessageResponse(BaseModel):
|
|
||||||
"""단순 메시지 응답"""
|
|
||||||
|
|
||||||
success: bool = Field(..., description="성공 여부")
|
|
||||||
message: str = Field(..., description="응답 메시지")
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"success": True,
|
|
||||||
"message": "작업이 완료되었습니다.",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
from app.social.services.account_service import SocialAccountService, social_account_service
|
||||||
|
from app.social.services.upload_service import SocialUploadService
|
||||||
|
from app.social.services.seo_service import SeoService, seo_service
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SocialAccountService",
|
||||||
|
"social_account_service",
|
||||||
|
"SocialUploadService",
|
||||||
|
"SeoService",
|
||||||
|
"seo_service",
|
||||||
|
]
|
||||||
|
|
@ -188,7 +188,7 @@ class SocialAccountService:
|
||||||
session=session,
|
session=session,
|
||||||
update_connected_at=is_reactivation, # 재활성화 시에만 연결 시간 업데이트
|
update_connected_at=is_reactivation, # 재활성화 시에만 연결 시간 업데이트
|
||||||
)
|
)
|
||||||
return self._to_response(existing_account)
|
return self.to_response(existing_account)
|
||||||
|
|
||||||
# 5. 새 소셜 계정 저장 (기존 계정이 없는 경우에만)
|
# 5. 새 소셜 계정 저장 (기존 계정이 없는 경우에만)
|
||||||
social_account = await self._create_social_account(
|
social_account = await self._create_social_account(
|
||||||
|
|
@ -204,7 +204,7 @@ class SocialAccountService:
|
||||||
f"account_id: {social_account.id}, platform: {platform.value}"
|
f"account_id: {social_account.id}, platform: {platform.value}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return self._to_response(social_account)
|
return self.to_response(social_account)
|
||||||
|
|
||||||
async def get_connected_accounts(
|
async def get_connected_accounts(
|
||||||
self,
|
self,
|
||||||
|
|
@ -241,7 +241,7 @@ class SocialAccountService:
|
||||||
for account in accounts:
|
for account in accounts:
|
||||||
await self._try_refresh_token(account, session)
|
await self._try_refresh_token(account, session)
|
||||||
|
|
||||||
return [self._to_response(account) for account in accounts]
|
return [self.to_response(account) for account in accounts]
|
||||||
|
|
||||||
async def refresh_all_tokens(
|
async def refresh_all_tokens(
|
||||||
self,
|
self,
|
||||||
|
|
@ -713,7 +713,7 @@ class SocialAccountService:
|
||||||
|
|
||||||
return account
|
return account
|
||||||
|
|
||||||
def _to_response(self, account: SocialAccount) -> SocialAccountResponse:
|
def to_response(self, account: SocialAccount) -> SocialAccountResponse:
|
||||||
"""
|
"""
|
||||||
SocialAccount를 SocialAccountResponse로 변환
|
SocialAccount를 SocialAccountResponse로 변환
|
||||||
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
"""
|
||||||
|
소셜 서비스 베이스 클래스
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
class BaseService:
|
||||||
|
"""서비스 레이어 베이스 클래스"""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession | None = None):
|
||||||
|
self.session = session
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
"""
|
||||||
|
유튜브 SEO 서비스
|
||||||
|
|
||||||
|
SEO description 생성 및 Redis 캐싱 로직을 처리합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from redis.asyncio import Redis
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import db_settings
|
||||||
|
from app.home.models import MarketingIntel, Project
|
||||||
|
from app.social.constants import YOUTUBE_SEO_HASH
|
||||||
|
from app.social.schemas import YoutubeDescriptionResponse
|
||||||
|
from app.user.models import User
|
||||||
|
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
||||||
|
from app.utils.prompts.prompts import yt_upload_prompt
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
redis_seo_client = Redis(
|
||||||
|
host=db_settings.REDIS_HOST,
|
||||||
|
port=db_settings.REDIS_PORT,
|
||||||
|
db=0,
|
||||||
|
decode_responses=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SeoService:
|
||||||
|
"""유튜브 SEO 비즈니스 로직 서비스"""
|
||||||
|
|
||||||
|
async def get_youtube_seo_description(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
current_user: User,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> YoutubeDescriptionResponse:
|
||||||
|
"""
|
||||||
|
유튜브 SEO description 생성
|
||||||
|
|
||||||
|
Redis 캐시 확인 후 miss이면 GPT로 생성하고 캐싱.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
f"[SEO_SERVICE] Try Cache - user: {current_user.user_uuid} / task_id: {task_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
cached = await self._get_from_redis(task_id)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
logger.info(f"[SEO_SERVICE] Cache miss - user: {current_user.user_uuid}")
|
||||||
|
result = await self._generate_seo_description(task_id, current_user, session)
|
||||||
|
await self._set_to_redis(task_id, result)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _generate_seo_description(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
current_user: User,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> YoutubeDescriptionResponse:
|
||||||
|
"""GPT를 사용하여 SEO description 생성"""
|
||||||
|
logger.info(f"[SEO_SERVICE] Generating SEO - user: {current_user.user_uuid}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
project_result = await session.execute(
|
||||||
|
select(Project)
|
||||||
|
.where(
|
||||||
|
Project.task_id == task_id,
|
||||||
|
Project.user_uuid == current_user.user_uuid,
|
||||||
|
)
|
||||||
|
.order_by(Project.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
project = project_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
marketing_result = await session.execute(
|
||||||
|
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
||||||
|
)
|
||||||
|
marketing_intelligence = marketing_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
hashtags = marketing_intelligence.intel_result["target_keywords"]
|
||||||
|
|
||||||
|
yt_seo_input_data = {
|
||||||
|
"customer_name": project.store_name,
|
||||||
|
"detail_region_info": project.detail_region_info,
|
||||||
|
"marketing_intelligence_summary": json.dumps(
|
||||||
|
marketing_intelligence.intel_result, ensure_ascii=False
|
||||||
|
),
|
||||||
|
"language": project.language,
|
||||||
|
"target_keywords": hashtags,
|
||||||
|
}
|
||||||
|
|
||||||
|
chatgpt = ChatgptService(timeout=180)
|
||||||
|
yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data)
|
||||||
|
|
||||||
|
return YoutubeDescriptionResponse(
|
||||||
|
title=yt_seo_output.title,
|
||||||
|
description=yt_seo_output.description,
|
||||||
|
keywords=hashtags,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[SEO_SERVICE] EXCEPTION - error: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_from_redis(self, task_id: str) -> YoutubeDescriptionResponse | None:
|
||||||
|
field = f"task_id:{task_id}"
|
||||||
|
yt_seo_info = await redis_seo_client.hget(YOUTUBE_SEO_HASH, field)
|
||||||
|
if yt_seo_info:
|
||||||
|
return YoutubeDescriptionResponse(**json.loads(yt_seo_info))
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _set_to_redis(self, task_id: str, yt_seo: YoutubeDescriptionResponse) -> None:
|
||||||
|
field = f"task_id:{task_id}"
|
||||||
|
yt_seo_info = json.dumps(yt_seo.model_dump(), ensure_ascii=False)
|
||||||
|
await redis_seo_client.hset(YOUTUBE_SEO_HASH, field, yt_seo_info)
|
||||||
|
await redis_seo_client.expire(YOUTUBE_SEO_HASH, 3600)
|
||||||
|
|
||||||
|
|
||||||
|
seo_service = SeoService()
|
||||||
|
|
@ -0,0 +1,391 @@
|
||||||
|
"""
|
||||||
|
소셜 업로드 서비스
|
||||||
|
|
||||||
|
업로드 요청, 상태 조회, 이력 조회, 재시도, 취소 관련 비즈니스 로직을 처리합니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from calendar import monthrange
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import BackgroundTasks, HTTPException, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import TIMEZONE
|
||||||
|
from app.social.constants import SocialPlatform, UploadStatus
|
||||||
|
from app.social.exceptions import SocialAccountNotFoundError, VideoNotFoundError
|
||||||
|
from app.social.models import SocialUpload
|
||||||
|
from app.social.schemas import (
|
||||||
|
MessageResponse,
|
||||||
|
SocialUploadHistoryItem,
|
||||||
|
SocialUploadHistoryResponse,
|
||||||
|
SocialUploadResponse,
|
||||||
|
SocialUploadStatusResponse,
|
||||||
|
SocialUploadRequest,
|
||||||
|
)
|
||||||
|
from app.social.services.account_service import SocialAccountService
|
||||||
|
from app.social.worker.upload_task import process_social_upload
|
||||||
|
from app.user.models import User
|
||||||
|
from app.video.models import Video
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SocialUploadService:
|
||||||
|
"""소셜 업로드 비즈니스 로직 서비스"""
|
||||||
|
|
||||||
|
def __init__(self, account_service: SocialAccountService):
|
||||||
|
self._account_service = account_service
|
||||||
|
|
||||||
|
async def request_upload(
|
||||||
|
self,
|
||||||
|
body: SocialUploadRequest,
|
||||||
|
current_user: User,
|
||||||
|
session: AsyncSession,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
) -> SocialUploadResponse:
|
||||||
|
"""
|
||||||
|
소셜 플랫폼 업로드 요청
|
||||||
|
|
||||||
|
영상 검증, 계정 확인, 중복 확인 후 업로드 레코드 생성.
|
||||||
|
즉시 업로드이면 백그라운드 태스크 등록, 예약이면 스케줄러가 처리.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
f"[UPLOAD_SERVICE] 업로드 요청 - "
|
||||||
|
f"user_uuid: {current_user.user_uuid}, "
|
||||||
|
f"video_id: {body.video_id}, "
|
||||||
|
f"social_account_id: {body.social_account_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. 영상 조회 및 검증
|
||||||
|
video_result = await session.execute(
|
||||||
|
select(Video).where(Video.id == body.video_id)
|
||||||
|
)
|
||||||
|
video = video_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not video:
|
||||||
|
logger.warning(f"[UPLOAD_SERVICE] 영상 없음 - video_id: {body.video_id}")
|
||||||
|
raise VideoNotFoundError(video_id=body.video_id)
|
||||||
|
|
||||||
|
if not video.result_movie_url:
|
||||||
|
logger.warning(f"[UPLOAD_SERVICE] 영상 URL 없음 - video_id: {body.video_id}")
|
||||||
|
raise VideoNotFoundError(
|
||||||
|
video_id=body.video_id,
|
||||||
|
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 소셜 계정 조회 및 소유권 검증
|
||||||
|
account = await self._account_service.get_account_by_id(
|
||||||
|
user_uuid=current_user.user_uuid,
|
||||||
|
account_id=body.social_account_id,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not account:
|
||||||
|
logger.warning(
|
||||||
|
f"[UPLOAD_SERVICE] 연동 계정 없음 - "
|
||||||
|
f"user_uuid: {current_user.user_uuid}, social_account_id: {body.social_account_id}"
|
||||||
|
)
|
||||||
|
raise SocialAccountNotFoundError()
|
||||||
|
|
||||||
|
# 3. 진행 중인 업로드 확인 (pending 또는 uploading 상태만)
|
||||||
|
in_progress_result = await session.execute(
|
||||||
|
select(SocialUpload).where(
|
||||||
|
SocialUpload.video_id == body.video_id,
|
||||||
|
SocialUpload.social_account_id == account.id,
|
||||||
|
SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
in_progress_upload = in_progress_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if in_progress_upload:
|
||||||
|
logger.info(
|
||||||
|
f"[UPLOAD_SERVICE] 진행 중인 업로드 존재 - upload_id: {in_progress_upload.id}"
|
||||||
|
)
|
||||||
|
return SocialUploadResponse(
|
||||||
|
success=True,
|
||||||
|
upload_id=in_progress_upload.id,
|
||||||
|
platform=account.platform,
|
||||||
|
status=in_progress_upload.status,
|
||||||
|
message="이미 업로드가 진행 중입니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. 업로드 순번 계산
|
||||||
|
max_seq_result = await session.execute(
|
||||||
|
select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where(
|
||||||
|
SocialUpload.video_id == body.video_id,
|
||||||
|
SocialUpload.social_account_id == account.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
next_seq = (max_seq_result.scalar() or 0) + 1
|
||||||
|
|
||||||
|
# 5. 새 업로드 레코드 생성
|
||||||
|
social_upload = SocialUpload(
|
||||||
|
user_uuid=current_user.user_uuid,
|
||||||
|
video_id=body.video_id,
|
||||||
|
social_account_id=account.id,
|
||||||
|
upload_seq=next_seq,
|
||||||
|
platform=account.platform,
|
||||||
|
status=UploadStatus.PENDING.value,
|
||||||
|
upload_progress=0,
|
||||||
|
title=body.title,
|
||||||
|
description=body.description,
|
||||||
|
tags=body.tags,
|
||||||
|
privacy_status=body.privacy_status.value,
|
||||||
|
scheduled_at=body.scheduled_at,
|
||||||
|
platform_options={
|
||||||
|
**(body.platform_options or {}),
|
||||||
|
"scheduled_at": body.scheduled_at.isoformat() if body.scheduled_at else None,
|
||||||
|
},
|
||||||
|
retry_count=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(social_upload)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(social_upload)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[UPLOAD_SERVICE] 업로드 레코드 생성 - "
|
||||||
|
f"upload_id: {social_upload.id}, video_id: {body.video_id}, "
|
||||||
|
f"account_id: {account.id}, upload_seq: {next_seq}, platform: {account.platform}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6. 즉시 업로드이면 백그라운드 태스크 등록
|
||||||
|
now_kst_naive = datetime.now(TIMEZONE).replace(tzinfo=None)
|
||||||
|
is_scheduled = body.scheduled_at and body.scheduled_at > now_kst_naive
|
||||||
|
if not is_scheduled:
|
||||||
|
background_tasks.add_task(process_social_upload, social_upload.id)
|
||||||
|
|
||||||
|
message = "예약 업로드가 등록되었습니다." if is_scheduled else "업로드 요청이 접수되었습니다."
|
||||||
|
return SocialUploadResponse(
|
||||||
|
success=True,
|
||||||
|
upload_id=social_upload.id,
|
||||||
|
platform=account.platform,
|
||||||
|
status=social_upload.status,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_upload_status(
|
||||||
|
self,
|
||||||
|
upload_id: int,
|
||||||
|
current_user: User,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> SocialUploadStatusResponse:
|
||||||
|
"""업로드 상태 조회"""
|
||||||
|
logger.info(f"[UPLOAD_SERVICE] 상태 조회 - upload_id: {upload_id}")
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(SocialUpload).where(
|
||||||
|
SocialUpload.id == upload_id,
|
||||||
|
SocialUpload.user_uuid == current_user.user_uuid,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
upload = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not upload:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="업로드 정보를 찾을 수 없습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
return SocialUploadStatusResponse(
|
||||||
|
upload_id=upload.id,
|
||||||
|
video_id=upload.video_id,
|
||||||
|
social_account_id=upload.social_account_id,
|
||||||
|
upload_seq=upload.upload_seq,
|
||||||
|
platform=upload.platform,
|
||||||
|
status=UploadStatus(upload.status),
|
||||||
|
upload_progress=upload.upload_progress,
|
||||||
|
title=upload.title,
|
||||||
|
platform_video_id=upload.platform_video_id,
|
||||||
|
platform_url=upload.platform_url,
|
||||||
|
error_message=upload.error_message,
|
||||||
|
retry_count=upload.retry_count,
|
||||||
|
scheduled_at=upload.scheduled_at,
|
||||||
|
created_at=upload.created_at,
|
||||||
|
uploaded_at=upload.uploaded_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_upload_history(
|
||||||
|
self,
|
||||||
|
current_user: User,
|
||||||
|
session: AsyncSession,
|
||||||
|
tab: str = "all",
|
||||||
|
platform: Optional[SocialPlatform] = None,
|
||||||
|
year: Optional[int] = None,
|
||||||
|
month: Optional[int] = None,
|
||||||
|
page: int = 1,
|
||||||
|
size: int = 20,
|
||||||
|
) -> SocialUploadHistoryResponse:
|
||||||
|
"""업로드 이력 조회 (탭/년월/플랫폼 필터, 페이지네이션)"""
|
||||||
|
now_kst = datetime.now(TIMEZONE)
|
||||||
|
target_year = year or now_kst.year
|
||||||
|
target_month = month or now_kst.month
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[UPLOAD_SERVICE] 이력 조회 - "
|
||||||
|
f"user_uuid: {current_user.user_uuid}, tab: {tab}, "
|
||||||
|
f"year: {target_year}, month: {target_month}, page: {page}, size: {size}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 월 범위 계산
|
||||||
|
last_day = monthrange(target_year, target_month)[1]
|
||||||
|
month_start = datetime(target_year, target_month, 1, 0, 0, 0)
|
||||||
|
month_end = datetime(target_year, target_month, last_day, 23, 59, 59)
|
||||||
|
|
||||||
|
# 기본 쿼리 (cancelled 제외)
|
||||||
|
base_conditions = [
|
||||||
|
SocialUpload.user_uuid == current_user.user_uuid,
|
||||||
|
SocialUpload.created_at >= month_start,
|
||||||
|
SocialUpload.created_at <= month_end,
|
||||||
|
SocialUpload.status != UploadStatus.CANCELLED.value,
|
||||||
|
]
|
||||||
|
|
||||||
|
query = select(SocialUpload).where(*base_conditions)
|
||||||
|
count_query = select(func.count(SocialUpload.id)).where(*base_conditions)
|
||||||
|
|
||||||
|
# 탭 필터 적용
|
||||||
|
if tab == "completed":
|
||||||
|
query = query.where(SocialUpload.status == UploadStatus.COMPLETED.value)
|
||||||
|
count_query = count_query.where(SocialUpload.status == UploadStatus.COMPLETED.value)
|
||||||
|
elif tab == "scheduled":
|
||||||
|
query = query.where(
|
||||||
|
SocialUpload.status == UploadStatus.PENDING.value,
|
||||||
|
SocialUpload.scheduled_at.isnot(None),
|
||||||
|
)
|
||||||
|
count_query = count_query.where(
|
||||||
|
SocialUpload.status == UploadStatus.PENDING.value,
|
||||||
|
SocialUpload.scheduled_at.isnot(None),
|
||||||
|
)
|
||||||
|
elif tab == "failed":
|
||||||
|
query = query.where(SocialUpload.status == UploadStatus.FAILED.value)
|
||||||
|
count_query = count_query.where(SocialUpload.status == UploadStatus.FAILED.value)
|
||||||
|
|
||||||
|
# 플랫폼 필터 적용
|
||||||
|
if platform:
|
||||||
|
query = query.where(SocialUpload.platform == platform.value)
|
||||||
|
count_query = count_query.where(SocialUpload.platform == platform.value)
|
||||||
|
|
||||||
|
# 총 개수 조회
|
||||||
|
total_result = await session.execute(count_query)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# 페이지네이션 적용
|
||||||
|
query = (
|
||||||
|
query.order_by(SocialUpload.created_at.desc())
|
||||||
|
.offset((page - 1) * size)
|
||||||
|
.limit(size)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.execute(query)
|
||||||
|
uploads = result.scalars().all()
|
||||||
|
|
||||||
|
items = [
|
||||||
|
SocialUploadHistoryItem(
|
||||||
|
upload_id=upload.id,
|
||||||
|
video_id=upload.video_id,
|
||||||
|
social_account_id=upload.social_account_id,
|
||||||
|
upload_seq=upload.upload_seq,
|
||||||
|
platform=upload.platform,
|
||||||
|
status=upload.status,
|
||||||
|
title=upload.title,
|
||||||
|
platform_url=upload.platform_url,
|
||||||
|
error_message=upload.error_message,
|
||||||
|
scheduled_at=upload.scheduled_at,
|
||||||
|
created_at=upload.created_at,
|
||||||
|
uploaded_at=upload.uploaded_at,
|
||||||
|
)
|
||||||
|
for upload in uploads
|
||||||
|
]
|
||||||
|
|
||||||
|
return SocialUploadHistoryResponse(
|
||||||
|
items=items,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
size=size,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def retry_upload(
|
||||||
|
self,
|
||||||
|
upload_id: int,
|
||||||
|
current_user: User,
|
||||||
|
session: AsyncSession,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
) -> SocialUploadResponse:
|
||||||
|
"""실패한 업로드 재시도"""
|
||||||
|
logger.info(f"[UPLOAD_SERVICE] 재시도 요청 - upload_id: {upload_id}")
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(SocialUpload).where(
|
||||||
|
SocialUpload.id == upload_id,
|
||||||
|
SocialUpload.user_uuid == current_user.user_uuid,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
upload = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not upload:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="업로드 정보를 찾을 수 없습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if upload.status not in [UploadStatus.FAILED.value, UploadStatus.CANCELLED.value]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="실패하거나 취소된 업로드만 재시도할 수 있습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 상태 초기화
|
||||||
|
upload.status = UploadStatus.PENDING.value
|
||||||
|
upload.upload_progress = 0
|
||||||
|
upload.error_message = None
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
background_tasks.add_task(process_social_upload, upload.id)
|
||||||
|
|
||||||
|
return SocialUploadResponse(
|
||||||
|
success=True,
|
||||||
|
upload_id=upload.id,
|
||||||
|
platform=upload.platform,
|
||||||
|
status=upload.status,
|
||||||
|
message="업로드 재시도가 요청되었습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def cancel_upload(
|
||||||
|
self,
|
||||||
|
upload_id: int,
|
||||||
|
current_user: User,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> MessageResponse:
|
||||||
|
"""대기 중인 업로드 취소"""
|
||||||
|
logger.info(f"[UPLOAD_SERVICE] 취소 요청 - upload_id: {upload_id}")
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(SocialUpload).where(
|
||||||
|
SocialUpload.id == upload_id,
|
||||||
|
SocialUpload.user_uuid == current_user.user_uuid,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
upload = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not upload:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="업로드 정보를 찾을 수 없습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if upload.status != UploadStatus.PENDING.value:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="대기 중인 업로드만 취소할 수 있습니다.",
|
||||||
|
)
|
||||||
|
|
||||||
|
upload.status = UploadStatus.CANCELLED.value
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return MessageResponse(
|
||||||
|
success=True,
|
||||||
|
message="업로드가 취소되었습니다.",
|
||||||
|
)
|
||||||
|
|
@ -18,6 +18,7 @@ from sqlalchemy import select
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from config import social_upload_settings
|
from config import social_upload_settings
|
||||||
|
from app.dashboard.tasks import insert_dashboard
|
||||||
from app.database.session import BackgroundSessionLocal
|
from app.database.session import BackgroundSessionLocal
|
||||||
from app.social.constants import SocialPlatform, UploadStatus
|
from app.social.constants import SocialPlatform, UploadStatus
|
||||||
from app.social.exceptions import TokenExpiredError, UploadError, UploadQuotaExceededError
|
from app.social.exceptions import TokenExpiredError, UploadError, UploadQuotaExceededError
|
||||||
|
|
@ -318,6 +319,7 @@ async def process_social_upload(upload_id: int) -> None:
|
||||||
f"platform_video_id: {result.platform_video_id}, "
|
f"platform_video_id: {result.platform_video_id}, "
|
||||||
f"url: {result.platform_url}"
|
f"url: {result.platform_url}"
|
||||||
)
|
)
|
||||||
|
await insert_dashboard(upload_id)
|
||||||
else:
|
else:
|
||||||
retry_count = await _increment_retry_count(upload_id)
|
retry_count = await _increment_retry_count(upload_id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,14 @@ from sqlalchemy import Connection, text
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.lyrics.schemas.lyrics_schema import (
|
from app.lyric.schemas.lyrics_schema import (
|
||||||
AttributeData,
|
AttributeData,
|
||||||
PromptTemplateData,
|
PromptTemplateData,
|
||||||
SongFormData,
|
SongFormData,
|
||||||
SongSampleData,
|
SongSampleData,
|
||||||
StoreData,
|
StoreData,
|
||||||
)
|
)
|
||||||
from app.utils.chatgpt_prompt import chatgpt_api
|
from app.utils.prompts.chatgpt_prompt import chatgpt_api
|
||||||
|
|
||||||
logger = get_logger("song")
|
logger = get_logger("song")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
from pydantic.main import BaseModel
|
||||||
|
|
||||||
|
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
||||||
|
from app.utils.prompts.prompts import image_autotag_prompt
|
||||||
|
from app.utils.prompts.schemas import SpaceType, Subject, Camera, MotionRecommended
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def autotag_image(image_url : str) -> list[str]: #tag_list
|
||||||
|
chatgpt = ChatgptService(model_type="gemini")
|
||||||
|
image_input_data = {
|
||||||
|
"img_url" : image_url,
|
||||||
|
"space_type" : list(SpaceType),
|
||||||
|
"subject" : list(Subject),
|
||||||
|
"camera" : list(Camera),
|
||||||
|
"motion_recommended" : list(MotionRecommended)
|
||||||
|
}
|
||||||
|
|
||||||
|
image_result = await chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_url, False)
|
||||||
|
return image_result
|
||||||
|
|
||||||
|
async def autotag_images(image_url_list : list[str]) -> list[dict]: #tag_list
|
||||||
|
chatgpt = ChatgptService(model_type="gemini")
|
||||||
|
image_input_data_list = [{
|
||||||
|
"img_url" : image_url,
|
||||||
|
"space_type" : list(SpaceType),
|
||||||
|
"subject" : list(Subject),
|
||||||
|
"camera" : list(Camera),
|
||||||
|
"motion_recommended" : list(MotionRecommended)
|
||||||
|
}for image_url in image_url_list]
|
||||||
|
|
||||||
|
image_result_tasks = [chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_input_data['img_url'], False, silent = True) for image_input_data in image_input_data_list]
|
||||||
|
image_result_list: list[BaseModel | BaseException] = await asyncio.gather(*image_result_tasks, return_exceptions=True)
|
||||||
|
MAX_RETRY = 3 # 하드코딩, 어떻게 처리할지는 나중에
|
||||||
|
for _ in range(MAX_RETRY):
|
||||||
|
failed_idx = [i for i, r in enumerate(image_result_list) if isinstance(r, Exception)]
|
||||||
|
print("Failed", failed_idx)
|
||||||
|
if not failed_idx:
|
||||||
|
break
|
||||||
|
retried = await asyncio.gather(
|
||||||
|
*[chatgpt.generate_structured_output(image_autotag_prompt, image_input_data_list[i], image_input_data_list[i]['img_url'], False, silent=True) for i in failed_idx],
|
||||||
|
return_exceptions=True
|
||||||
|
)
|
||||||
|
for i, result in zip(failed_idx, retried):
|
||||||
|
image_result_list[i] = result
|
||||||
|
|
||||||
|
print("Failed", failed_idx)
|
||||||
|
return image_result_list
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from openai import AsyncOpenAI
|
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
from config import apikey_settings, recovery_settings
|
|
||||||
from app.utils.prompts.prompts import Prompt
|
|
||||||
|
|
||||||
|
|
||||||
# 로거 설정
|
|
||||||
logger = get_logger("chatgpt")
|
|
||||||
|
|
||||||
|
|
||||||
class ChatGPTResponseError(Exception):
|
|
||||||
"""ChatGPT API 응답 에러"""
|
|
||||||
def __init__(self, status: str, error_code: str = None, error_message: str = None):
|
|
||||||
self.status = status
|
|
||||||
self.error_code = error_code
|
|
||||||
self.error_message = error_message
|
|
||||||
super().__init__(f"ChatGPT response failed: status={status}, code={error_code}, message={error_message}")
|
|
||||||
|
|
||||||
|
|
||||||
class ChatgptService:
|
|
||||||
"""ChatGPT API 서비스 클래스
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, timeout: float = None):
|
|
||||||
self.timeout = timeout or recovery_settings.CHATGPT_TIMEOUT
|
|
||||||
self.max_retries = recovery_settings.CHATGPT_MAX_RETRIES
|
|
||||||
self.client = AsyncOpenAI(
|
|
||||||
api_key=apikey_settings.CHATGPT_API_KEY,
|
|
||||||
timeout=self.timeout
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _call_pydantic_output(self, prompt : str, output_format : BaseModel, model : str) -> BaseModel: # 입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
|
|
||||||
content = [{"type": "input_text", "text": prompt}]
|
|
||||||
last_error = None
|
|
||||||
for attempt in range(self.max_retries + 1):
|
|
||||||
response = await self.client.responses.parse(
|
|
||||||
model=model,
|
|
||||||
input=[{"role": "user", "content": content}],
|
|
||||||
text_format=output_format
|
|
||||||
)
|
|
||||||
# Response 디버그 로깅
|
|
||||||
logger.debug(f"[ChatgptService] attempt: {attempt}")
|
|
||||||
logger.debug(f"[ChatgptService] Response ID: {response.id}")
|
|
||||||
logger.debug(f"[ChatgptService] Response status: {response.status}")
|
|
||||||
logger.debug(f"[ChatgptService] Response model: {response.model}")
|
|
||||||
|
|
||||||
# status 확인: completed, failed, incomplete, cancelled, queued, in_progress
|
|
||||||
if response.status == "completed":
|
|
||||||
logger.debug(f"[ChatgptService] Response output_text: {response.output_text[:200]}..." if len(response.output_text) > 200 else f"[ChatgptService] Response output_text: {response.output_text}")
|
|
||||||
structured_output = response.output_parsed
|
|
||||||
return structured_output #.model_dump() or {}
|
|
||||||
|
|
||||||
# 에러 상태 처리
|
|
||||||
if response.status == "failed":
|
|
||||||
error_code = getattr(response.error, 'code', None) if response.error else None
|
|
||||||
error_message = getattr(response.error, 'message', None) if response.error else None
|
|
||||||
logger.warning(f"[ChatgptService] Response failed (attempt {attempt + 1}/{self.max_retries + 1}): code={error_code}, message={error_message}")
|
|
||||||
last_error = ChatGPTResponseError(response.status, error_code, error_message)
|
|
||||||
|
|
||||||
elif response.status == "incomplete":
|
|
||||||
reason = getattr(response.incomplete_details, 'reason', None) if response.incomplete_details else None
|
|
||||||
logger.warning(f"[ChatgptService] Response incomplete (attempt {attempt + 1}/{self.max_retries + 1}): reason={reason}")
|
|
||||||
last_error = ChatGPTResponseError(response.status, reason, f"Response incomplete: {reason}")
|
|
||||||
|
|
||||||
else:
|
|
||||||
# cancelled, queued, in_progress 등 예상치 못한 상태
|
|
||||||
logger.warning(f"[ChatgptService] Unexpected response status (attempt {attempt + 1}/{self.max_retries + 1}): {response.status}")
|
|
||||||
last_error = ChatGPTResponseError(response.status, None, f"Unexpected status: {response.status}")
|
|
||||||
|
|
||||||
# 마지막 시도가 아니면 재시도
|
|
||||||
if attempt < self.max_retries:
|
|
||||||
logger.info(f"[ChatgptService] Retrying request...")
|
|
||||||
|
|
||||||
# 모든 재시도 실패
|
|
||||||
logger.error(f"[ChatgptService] All retries exhausted. Last error: {last_error}")
|
|
||||||
raise last_error
|
|
||||||
|
|
||||||
async def generate_structured_output(
|
|
||||||
self,
|
|
||||||
prompt : Prompt,
|
|
||||||
input_data : dict,
|
|
||||||
) -> BaseModel:
|
|
||||||
prompt_text = prompt.build_prompt(input_data)
|
|
||||||
|
|
||||||
logger.debug(f"[ChatgptService] Generated Prompt (length: {len(prompt_text)})")
|
|
||||||
logger.info(f"[ChatgptService] Starting GPT request with structured output with model: {prompt.prompt_model}")
|
|
||||||
|
|
||||||
# GPT API 호출
|
|
||||||
#response = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
|
|
||||||
response = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model)
|
|
||||||
return response
|
|
||||||
|
|
@ -31,11 +31,13 @@ response = await creatomate.make_creatomate_call(template_id, modifications)
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import time
|
import time
|
||||||
|
from enum import StrEnum
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
import traceback
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
|
from app.utils.prompts.schemas.image import SpaceType,Subject,Camera,MotionRecommended,NarrativePhase
|
||||||
from config import apikey_settings, creatomate_settings, recovery_settings
|
from config import apikey_settings, creatomate_settings, recovery_settings
|
||||||
|
|
||||||
# 로거 설정
|
# 로거 설정
|
||||||
|
|
@ -220,6 +222,28 @@ autotext_template_h_1 = {
|
||||||
"stroke_color": "#333333",
|
"stroke_color": "#333333",
|
||||||
"stroke_width": "0.2 vmin"
|
"stroke_width": "0.2 vmin"
|
||||||
}
|
}
|
||||||
|
DVST0001 = "75161273-0422-4771-adeb-816bd7263fb0"
|
||||||
|
DVST0002 = "c68cf750-bc40-485a-a2c5-3f9fe301e386"
|
||||||
|
DVST0003 = "e1fb5b00-1f02-4f63-99fa-7524b433ba47"
|
||||||
|
DHST0001 = "660be601-080a-43ea-bf0f-adcf4596fa98"
|
||||||
|
DHST0002 = "3f194cc7-464e-4581-9db2-179d42d3e40f"
|
||||||
|
DHST0003 = "f45df555-2956-4a13-9004-ead047070b3d"
|
||||||
|
DVST0001T = "fe11aeab-ff29-4bc8-9f75-c695c7e243e6"
|
||||||
|
HST_LIST = [DHST0001,DHST0002,DHST0003]
|
||||||
|
VST_LIST = [DVST0001,DVST0002,DVST0003, DVST0001T]
|
||||||
|
|
||||||
|
SCENE_TRACK = 1
|
||||||
|
AUDIO_TRACK = 2
|
||||||
|
SUBTITLE_TRACK = 3
|
||||||
|
KEYWORD_TRACK = 4
|
||||||
|
|
||||||
|
def select_template(orientation:OrientationType):
|
||||||
|
if orientation == "horizontal":
|
||||||
|
return DHST0001
|
||||||
|
elif orientation == "vertical":
|
||||||
|
return DVST0001T
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
async def get_shared_client() -> httpx.AsyncClient:
|
async def get_shared_client() -> httpx.AsyncClient:
|
||||||
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
||||||
|
|
@ -264,23 +288,10 @@ class CreatomateService:
|
||||||
|
|
||||||
BASE_URL = "https://api.creatomate.com"
|
BASE_URL = "https://api.creatomate.com"
|
||||||
|
|
||||||
# 템플릿 설정 (config에서 가져옴)
|
|
||||||
TEMPLATE_CONFIG = {
|
|
||||||
"horizontal": {
|
|
||||||
"template_id": creatomate_settings.TEMPLATE_ID_HORIZONTAL,
|
|
||||||
"duration": creatomate_settings.TEMPLATE_DURATION_HORIZONTAL,
|
|
||||||
},
|
|
||||||
"vertical": {
|
|
||||||
"template_id": creatomate_settings.TEMPLATE_ID_VERTICAL,
|
|
||||||
"duration": creatomate_settings.TEMPLATE_DURATION_VERTICAL,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
orientation: OrientationType = "vertical",
|
orientation: OrientationType = "vertical"
|
||||||
target_duration: float | None = None,
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -294,14 +305,7 @@ class CreatomateService:
|
||||||
self.orientation = orientation
|
self.orientation = orientation
|
||||||
|
|
||||||
# orientation에 따른 템플릿 설정 가져오기
|
# orientation에 따른 템플릿 설정 가져오기
|
||||||
config = self.TEMPLATE_CONFIG.get(
|
self.template_id = select_template(orientation)
|
||||||
orientation, self.TEMPLATE_CONFIG["vertical"]
|
|
||||||
)
|
|
||||||
self.template_id = config["template_id"]
|
|
||||||
self.target_duration = (
|
|
||||||
target_duration if target_duration is not None else config["duration"]
|
|
||||||
)
|
|
||||||
|
|
||||||
self.headers = {
|
self.headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
|
@ -398,14 +402,6 @@ class CreatomateService:
|
||||||
|
|
||||||
return copy.deepcopy(data)
|
return copy.deepcopy(data)
|
||||||
|
|
||||||
# 하위 호환성을 위한 별칭 (deprecated)
|
|
||||||
async def get_one_template_data_async(self, template_id: str) -> dict:
|
|
||||||
"""특정 템플릿 ID로 템플릿 정보를 조회합니다.
|
|
||||||
|
|
||||||
Deprecated: get_one_template_data()를 사용하세요.
|
|
||||||
"""
|
|
||||||
return await self.get_one_template_data(template_id)
|
|
||||||
|
|
||||||
def parse_template_component_name(self, template_source: list) -> dict:
|
def parse_template_component_name(self, template_source: list) -> dict:
|
||||||
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
|
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
|
||||||
|
|
||||||
|
|
@ -433,51 +429,111 @@ class CreatomateService:
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def template_connect_resource_blackbox(
|
async def parse_template_name_tag(resource_name : str) -> list:
|
||||||
|
tag_list = []
|
||||||
|
tag_list = resource_name.split("_")
|
||||||
|
|
||||||
|
return tag_list
|
||||||
|
|
||||||
|
|
||||||
|
def counting_component(
|
||||||
self,
|
self,
|
||||||
template_id: str,
|
template : dict,
|
||||||
image_url_list: list[str],
|
target_template_type : str
|
||||||
lyric: str,
|
) -> list:
|
||||||
|
source_elements = template["source"]["elements"]
|
||||||
|
template_component_data = self.parse_template_component_name(source_elements)
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
for _, (_, template_type) in enumerate(template_component_data.items()):
|
||||||
|
if template_type == target_template_type:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
def template_matching_taged_image(
|
||||||
|
self,
|
||||||
|
template : dict,
|
||||||
|
taged_image_list : list, # [{"image_name" : str , "image_tag" : dict}]
|
||||||
music_url: str,
|
music_url: str,
|
||||||
address: str = None
|
address : str,
|
||||||
) -> dict:
|
duplicate : bool = False
|
||||||
"""템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
|
) -> list:
|
||||||
|
source_elements = template["source"]["elements"]
|
||||||
|
template_component_data = self.parse_template_component_name(source_elements)
|
||||||
|
|
||||||
Note:
|
|
||||||
- 이미지는 순차적으로 집어넣기
|
|
||||||
- 가사는 개행마다 한 텍스트 삽입
|
|
||||||
- Template에 audio-music 항목이 있어야 함
|
|
||||||
"""
|
|
||||||
template_data = await self.get_one_template_data(template_id)
|
|
||||||
template_component_data = self.parse_template_component_name(
|
|
||||||
template_data["source"]["elements"]
|
|
||||||
)
|
|
||||||
|
|
||||||
lyric = lyric.replace("\r", "")
|
|
||||||
lyric_splited = lyric.split("\n")
|
|
||||||
modifications = {}
|
modifications = {}
|
||||||
|
|
||||||
for idx, (template_component_name, template_type) in enumerate(
|
for slot_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[
|
image_score_list = self.calculate_image_slot_score_multi(taged_image_list, template_component_name)
|
||||||
idx % len(image_url_list)
|
maximum_idx = image_score_list.index(max(image_score_list))
|
||||||
]
|
if duplicate:
|
||||||
|
selected = taged_image_list[maximum_idx]
|
||||||
|
else:
|
||||||
|
selected = taged_image_list.pop(maximum_idx)
|
||||||
|
image_name = selected["image_url"]
|
||||||
|
modifications[template_component_name] =image_name
|
||||||
|
pass
|
||||||
case "text":
|
case "text":
|
||||||
if "address_input" in template_component_name:
|
if "address_input" in template_component_name:
|
||||||
modifications[template_component_name] = address
|
modifications[template_component_name] = address
|
||||||
|
|
||||||
modifications["audio-music"] = music_url
|
modifications["audio-music"] = music_url
|
||||||
|
|
||||||
return modifications
|
return modifications
|
||||||
|
|
||||||
|
def calculate_image_slot_score_multi(self, taged_image_list : list[dict], slot_name : str):
|
||||||
|
image_tag_list = [taged_image["image_tag"] for taged_image in taged_image_list]
|
||||||
|
slot_tag_dict = self.parse_slot_name_to_tag(slot_name)
|
||||||
|
image_score_list = [0] * len(image_tag_list)
|
||||||
|
|
||||||
|
for slot_tag_cate, slot_tag_item in slot_tag_dict.items():
|
||||||
|
if slot_tag_cate == "narrative_preference":
|
||||||
|
slot_tag_narrative = slot_tag_item
|
||||||
|
continue
|
||||||
|
|
||||||
|
match slot_tag_cate:
|
||||||
|
case "space_type":
|
||||||
|
weight = 2
|
||||||
|
case "subject" :
|
||||||
|
weight = 2
|
||||||
|
case "camera":
|
||||||
|
weight = 1
|
||||||
|
case "motion_recommended" :
|
||||||
|
weight = 0.5
|
||||||
|
case _:
|
||||||
|
raise
|
||||||
|
|
||||||
|
for idx, image_tag in enumerate(image_tag_list):
|
||||||
|
if slot_tag_item.value in image_tag[slot_tag_cate]: #collect!
|
||||||
|
image_score_list[idx] += weight
|
||||||
|
|
||||||
|
for idx, image_tag in enumerate(image_tag_list):
|
||||||
|
image_narrative_score = image_tag["narrative_preference"][slot_tag_narrative]
|
||||||
|
image_score_list[idx] = image_score_list[idx] * image_narrative_score
|
||||||
|
|
||||||
|
return image_score_list
|
||||||
|
|
||||||
|
def parse_slot_name_to_tag(self, slot_name : str) -> dict[str, StrEnum]:
|
||||||
|
tag_list = slot_name.split("-")
|
||||||
|
space_type = SpaceType(tag_list[0])
|
||||||
|
subject = Subject(tag_list[1])
|
||||||
|
camera = Camera(tag_list[2])
|
||||||
|
motion = MotionRecommended(tag_list[3])
|
||||||
|
narrative = NarrativePhase(tag_list[4])
|
||||||
|
tag_dict = {
|
||||||
|
"space_type" : space_type,
|
||||||
|
"subject" : subject,
|
||||||
|
"camera" : camera,
|
||||||
|
"motion_recommended" : motion,
|
||||||
|
"narrative_preference" : narrative,
|
||||||
|
}
|
||||||
|
return tag_dict
|
||||||
|
|
||||||
def elements_connect_resource_blackbox(
|
def elements_connect_resource_blackbox(
|
||||||
self,
|
self,
|
||||||
elements: list,
|
elements: list,
|
||||||
image_url_list: list[str],
|
image_url_list: list[str],
|
||||||
lyric: str,
|
|
||||||
music_url: str,
|
music_url: str,
|
||||||
address: str = None
|
address: str = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|
@ -673,14 +729,6 @@ class CreatomateService:
|
||||||
original_response={"last_error": str(last_error)},
|
original_response={"last_error": str(last_error)},
|
||||||
)
|
)
|
||||||
|
|
||||||
# 하위 호환성을 위한 별칭 (deprecated)
|
|
||||||
async def make_creatomate_custom_call_async(self, source: dict) -> dict:
|
|
||||||
"""템플릿 없이 Creatomate에 커스텀 렌더링 요청을 보냅니다.
|
|
||||||
|
|
||||||
Deprecated: make_creatomate_custom_call()을 사용하세요.
|
|
||||||
"""
|
|
||||||
return await self.make_creatomate_custom_call(source)
|
|
||||||
|
|
||||||
async def get_render_status(self, render_id: str) -> dict:
|
async def get_render_status(self, render_id: str) -> dict:
|
||||||
"""렌더링 작업의 상태를 조회합니다.
|
"""렌더링 작업의 상태를 조회합니다.
|
||||||
|
|
||||||
|
|
@ -704,47 +752,58 @@ class CreatomateService:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
# 하위 호환성을 위한 별칭 (deprecated)
|
|
||||||
async def get_render_status_async(self, render_id: str) -> dict:
|
|
||||||
"""렌더링 작업의 상태를 조회합니다.
|
|
||||||
|
|
||||||
Deprecated: get_render_status()를 사용하세요.
|
|
||||||
"""
|
|
||||||
return await self.get_render_status(render_id)
|
|
||||||
|
|
||||||
def calc_scene_duration(self, template: dict) -> float:
|
def calc_scene_duration(self, template: dict) -> float:
|
||||||
"""템플릿의 전체 장면 duration을 계산합니다."""
|
"""템플릿의 전체 장면 duration을 계산합니다."""
|
||||||
total_template_duration = 0.0
|
total_template_duration = 0.0
|
||||||
|
track_maximum_duration = {
|
||||||
|
SCENE_TRACK : 0,
|
||||||
|
SUBTITLE_TRACK : 0,
|
||||||
|
KEYWORD_TRACK : 0
|
||||||
|
}
|
||||||
for elem in template["source"]["elements"]:
|
for elem in template["source"]["elements"]:
|
||||||
try:
|
try:
|
||||||
if elem["type"] == "audio":
|
if elem["track"] not in track_maximum_duration:
|
||||||
continue
|
continue
|
||||||
total_template_duration += elem["duration"]
|
if "time" not in elem or elem["time"] == 0: # elem is auto / 만약 마지막 elem이 auto인데 그 앞에 time이 있는 elem 일 시 버그 발생 확률 있음
|
||||||
|
track_maximum_duration[elem["track"]] += 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 "transition" in animation and animation["transition"]:
|
||||||
total_template_duration -= animation["duration"]
|
track_maximum_duration[elem["track"]] -= animation["duration"]
|
||||||
|
else:
|
||||||
|
track_maximum_duration[elem["track"]] = max(track_maximum_duration[elem["track"]], elem["time"] + elem["duration"])
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.debug(traceback.format_exc())
|
||||||
logger.error(f"[calc_scene_duration] Error processing element: {elem}, {e}")
|
logger.error(f"[calc_scene_duration] Error processing element: {elem}, {e}")
|
||||||
|
|
||||||
|
total_template_duration = max(track_maximum_duration.values())
|
||||||
|
|
||||||
return total_template_duration
|
return total_template_duration
|
||||||
|
|
||||||
def extend_template_duration(self, template: dict, target_duration: float) -> dict:
|
def extend_template_duration(self, template: dict, target_duration: float) -> dict:
|
||||||
"""템플릿의 duration을 target_duration으로 확장합니다."""
|
"""템플릿의 duration을 target_duration으로 확장합니다."""
|
||||||
template["duration"] = target_duration + 0.5 # 늘린것보단 짧게
|
# template["duration"] = target_duration + 0.5 # 늘린것보단 짧게
|
||||||
target_duration += 1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것
|
# target_duration += 1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것
|
||||||
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
|
||||||
|
if elem["track"] == AUDIO_TRACK : # audio track은 패스
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if "time" in elem:
|
||||||
|
elem["time"] = elem["time"] * extend_rate
|
||||||
|
if "duration" in elem:
|
||||||
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"]:
|
||||||
|
|
@ -786,3 +845,24 @@ class CreatomateService:
|
||||||
case "horizontal":
|
case "horizontal":
|
||||||
return autotext_template_h_1
|
return autotext_template_h_1
|
||||||
|
|
||||||
|
def extract_text_format_from_template(self, template:dict):
|
||||||
|
keyword_list = []
|
||||||
|
subtitle_list = []
|
||||||
|
for elem in template["source"]["elements"]:
|
||||||
|
try: #최상위 내 텍스트만 검사
|
||||||
|
if elem["type"] == "text":
|
||||||
|
if elem["track"] == SUBTITLE_TRACK:
|
||||||
|
subtitle_list.append(elem["name"])
|
||||||
|
elif elem["track"] == KEYWORD_TRACK:
|
||||||
|
keyword_list.append(elem["name"])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[extend_template_duration] Error processing element: {elem}, {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
assert(len(keyword_list)==len(subtitle_list))
|
||||||
|
except Exception as E:
|
||||||
|
logger.error("this template does not have same amount of keyword and subtitle.")
|
||||||
|
pitching_list = keyword_list + subtitle_list
|
||||||
|
return pitching_list
|
||||||
|
|
@ -16,6 +16,10 @@ class GraphQLException(Exception):
|
||||||
"""GraphQL 요청 실패 시 발생하는 예외"""
|
"""GraphQL 요청 실패 시 발생하는 예외"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
class URLNotFoundException(Exception):
|
||||||
|
"""Place ID 발견 불가능 시 발생하는 예외"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class CrawlingTimeoutException(Exception):
|
class CrawlingTimeoutException(Exception):
|
||||||
"""크롤링 타임아웃 시 발생하는 예외"""
|
"""크롤링 타임아웃 시 발생하는 예외"""
|
||||||
|
|
@ -86,15 +90,14 @@ query getAccommodation($id: String!, $deviceType: String) {
|
||||||
async with session.get(self.url) as response:
|
async with session.get(self.url) as response:
|
||||||
self.url = str(response.url)
|
self.url = str(response.url)
|
||||||
else:
|
else:
|
||||||
raise GraphQLException("This URL does not contain a place ID")
|
raise URLNotFoundException("This URL does not contain a place ID")
|
||||||
|
|
||||||
match = re.search(place_pattern, self.url)
|
match = re.search(place_pattern, self.url)
|
||||||
if not match:
|
if not match:
|
||||||
raise GraphQLException("Failed to parse place ID from URL")
|
raise URLNotFoundException("Failed to parse place ID from URL")
|
||||||
return match[1]
|
return match[1]
|
||||||
|
|
||||||
async def scrap(self):
|
async def scrap(self):
|
||||||
try:
|
|
||||||
place_id = await self.parse_url()
|
place_id = await self.parse_url()
|
||||||
data = await self._call_get_accommodation(place_id)
|
data = await self._call_get_accommodation(place_id)
|
||||||
self.rawdata = data
|
self.rawdata = data
|
||||||
|
|
@ -110,11 +113,6 @@ query getAccommodation($id: String!, $deviceType: String) {
|
||||||
self.facility_info = fac_data
|
self.facility_info = fac_data
|
||||||
self.scrap_type = "GraphQL"
|
self.scrap_type = "GraphQL"
|
||||||
|
|
||||||
except GraphQLException:
|
|
||||||
logger.debug("GraphQL failed, fallback to Playwright")
|
|
||||||
self.scrap_type = "Playwright"
|
|
||||||
pass # 나중에 pw 이용한 crawling으로 fallback 추가
|
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
async def _call_get_accommodation(self, place_id: str) -> dict:
|
async def _call_get_accommodation(self, place_id: str) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Optional
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
from config import apikey_settings, recovery_settings
|
||||||
|
from app.utils.prompts.prompts import Prompt
|
||||||
|
|
||||||
|
|
||||||
|
# 로거 설정
|
||||||
|
logger = get_logger("chatgpt")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatGPTResponseError(Exception):
|
||||||
|
"""ChatGPT API 응답 에러"""
|
||||||
|
def __init__(self, status: str, error_code: str = None, error_message: str = None):
|
||||||
|
self.status = status
|
||||||
|
self.error_code = error_code
|
||||||
|
self.error_message = error_message
|
||||||
|
super().__init__(f"ChatGPT response failed: status={status}, code={error_code}, message={error_message}")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatgptService:
|
||||||
|
"""ChatGPT API 서비스 클래스
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_type : str
|
||||||
|
|
||||||
|
def __init__(self, model_type:str = "gpt", timeout: float = None):
|
||||||
|
self.timeout = timeout or recovery_settings.CHATGPT_TIMEOUT
|
||||||
|
self.max_retries = recovery_settings.CHATGPT_MAX_RETRIES
|
||||||
|
self.model_type = model_type
|
||||||
|
match model_type:
|
||||||
|
case "gpt":
|
||||||
|
self.client = AsyncOpenAI(
|
||||||
|
api_key=apikey_settings.CHATGPT_API_KEY,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
case "gemini":
|
||||||
|
self.client = AsyncOpenAI(
|
||||||
|
api_key=apikey_settings.GEMINI_API_KEY,
|
||||||
|
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
case _:
|
||||||
|
raise NotImplementedError(f"Unknown Provider : {model_type}")
|
||||||
|
|
||||||
|
async def _call_pydantic_output(
|
||||||
|
self,
|
||||||
|
prompt : str,
|
||||||
|
output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
|
||||||
|
model : str,
|
||||||
|
img_url : str,
|
||||||
|
image_detail_high : bool) -> BaseModel:
|
||||||
|
content = []
|
||||||
|
if img_url:
|
||||||
|
content.append({
|
||||||
|
"type" : "input_image",
|
||||||
|
"image_url" : img_url,
|
||||||
|
"detail": "high" if image_detail_high else "low"
|
||||||
|
})
|
||||||
|
content.append({
|
||||||
|
"type": "input_text",
|
||||||
|
"text": prompt}
|
||||||
|
)
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(self.max_retries + 1):
|
||||||
|
response = await self.client.responses.parse(
|
||||||
|
model=model,
|
||||||
|
input=[{"role": "user", "content": content}],
|
||||||
|
text_format=output_format
|
||||||
|
)
|
||||||
|
# Response 디버그 로깅
|
||||||
|
logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
|
||||||
|
logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
|
||||||
|
logger.debug(f"[ChatgptService({self.model_type})] Response status: {response.status}")
|
||||||
|
logger.debug(f"[ChatgptService({self.model_type})] Response model: {response.model}")
|
||||||
|
|
||||||
|
# status 확인: completed, failed, incomplete, cancelled, queued, in_progress
|
||||||
|
if response.status == "completed":
|
||||||
|
logger.debug(f"[ChatgptService({self.model_type})] Response output_text: {response.output_text[:200]}..." if len(response.output_text) > 200 else f"[ChatgptService] Response output_text: {response.output_text}")
|
||||||
|
structured_output = response.output_parsed
|
||||||
|
return structured_output #.model_dump() or {}
|
||||||
|
|
||||||
|
# 에러 상태 처리
|
||||||
|
if response.status == "failed":
|
||||||
|
error_code = getattr(response.error, 'code', None) if response.error else None
|
||||||
|
error_message = getattr(response.error, 'message', None) if response.error else None
|
||||||
|
logger.warning(f"[ChatgptService({self.model_type})] Response failed (attempt {attempt + 1}/{self.max_retries + 1}): code={error_code}, message={error_message}")
|
||||||
|
last_error = ChatGPTResponseError(response.status, error_code, error_message)
|
||||||
|
|
||||||
|
elif response.status == "incomplete":
|
||||||
|
reason = getattr(response.incomplete_details, 'reason', None) if response.incomplete_details else None
|
||||||
|
logger.warning(f"[ChatgptService({self.model_type})] Response incomplete (attempt {attempt + 1}/{self.max_retries + 1}): reason={reason}")
|
||||||
|
last_error = ChatGPTResponseError(response.status, reason, f"Response incomplete: {reason}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# cancelled, queued, in_progress 등 예상치 못한 상태
|
||||||
|
logger.warning(f"[ChatgptService({self.model_type})] Unexpected response status (attempt {attempt + 1}/{self.max_retries + 1}): {response.status}")
|
||||||
|
last_error = ChatGPTResponseError(response.status, None, f"Unexpected status: {response.status}")
|
||||||
|
|
||||||
|
# 마지막 시도가 아니면 재시도
|
||||||
|
if attempt < self.max_retries:
|
||||||
|
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
|
||||||
|
|
||||||
|
# 모든 재시도 실패
|
||||||
|
logger.error(f"[ChatgptService({self.model_type})] All retries exhausted. Last error: {last_error}")
|
||||||
|
raise last_error
|
||||||
|
|
||||||
|
async def _call_pydantic_output_chat_completion( # alter version
|
||||||
|
self,
|
||||||
|
prompt : str,
|
||||||
|
output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
|
||||||
|
model : str,
|
||||||
|
img_url : str,
|
||||||
|
image_detail_high : bool) -> BaseModel:
|
||||||
|
content = []
|
||||||
|
if img_url:
|
||||||
|
content.append({
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": img_url,
|
||||||
|
"detail": "high" if image_detail_high else "low"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
content.append({
|
||||||
|
"type": "text",
|
||||||
|
"text": prompt
|
||||||
|
})
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(self.max_retries + 1):
|
||||||
|
response = await self.client.beta.chat.completions.parse(
|
||||||
|
model=model,
|
||||||
|
messages=[{"role": "user", "content": content}],
|
||||||
|
response_format=output_format
|
||||||
|
)
|
||||||
|
# Response 디버그 로깅
|
||||||
|
logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
|
||||||
|
logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
|
||||||
|
logger.debug(f"[ChatgptService({self.model_type})] Response finish_reason: {response.id}")
|
||||||
|
logger.debug(f"[ChatgptService({self.model_type})] Response model: {response.model}")
|
||||||
|
|
||||||
|
choice = response.choices[0]
|
||||||
|
finish_reason = choice.finish_reason
|
||||||
|
|
||||||
|
if finish_reason == "stop":
|
||||||
|
output_text = choice.message.content or ""
|
||||||
|
logger.debug(f"[ChatgptService({self.model_type})] Response output_text: {output_text[:200]}..." if len(output_text) > 200 else f"[ChatgptService] Response output_text: {output_text}")
|
||||||
|
return choice.message.parsed
|
||||||
|
|
||||||
|
elif finish_reason == "length":
|
||||||
|
logger.warning(f"[ChatgptService({self.model_type})] Response incomplete - token limit reached (attempt {attempt + 1}/{self.max_retries + 1})")
|
||||||
|
last_error = ChatGPTResponseError("incomplete", finish_reason, "Response incomplete: max tokens reached")
|
||||||
|
|
||||||
|
elif finish_reason == "content_filter":
|
||||||
|
logger.warning(f"[ChatgptService({self.model_type})] Response blocked by content filter (attempt {attempt + 1}/{self.max_retries + 1})")
|
||||||
|
last_error = ChatGPTResponseError("failed", finish_reason, "Response blocked by content filter")
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.warning(f"[ChatgptService({self.model_type})] Unexpected finish_reason (attempt {attempt + 1}/{self.max_retries + 1}): {finish_reason}")
|
||||||
|
last_error = ChatGPTResponseError("failed", finish_reason, f"Unexpected finish_reason: {finish_reason}")
|
||||||
|
|
||||||
|
# 마지막 시도가 아니면 재시도
|
||||||
|
if attempt < self.max_retries:
|
||||||
|
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
|
||||||
|
|
||||||
|
# 모든 재시도 실패
|
||||||
|
logger.error(f"[ChatgptService({self.model_type})] All retries exhausted. Last error: {last_error}")
|
||||||
|
raise last_error
|
||||||
|
|
||||||
|
async def generate_structured_output(
|
||||||
|
self,
|
||||||
|
prompt : Prompt,
|
||||||
|
input_data : dict,
|
||||||
|
img_url : Optional[str] = None,
|
||||||
|
img_detail_high : bool = False,
|
||||||
|
silent : bool = False
|
||||||
|
) -> BaseModel:
|
||||||
|
prompt_text = prompt.build_prompt(input_data, silent)
|
||||||
|
|
||||||
|
logger.debug(f"[ChatgptService({self.model_type})] Generated Prompt (length: {len(prompt_text)})")
|
||||||
|
if not silent:
|
||||||
|
logger.info(f"[ChatgptService({self.model_type})] Starting GPT request with structured output with model: {prompt.prompt_model}")
|
||||||
|
|
||||||
|
# GPT API 호출
|
||||||
|
#parsed = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
|
||||||
|
# parsed = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
|
||||||
|
parsed = await self._call_pydantic_output_chat_completion(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
|
||||||
|
return parsed
|
||||||
|
|
@ -1,65 +1,89 @@
|
||||||
import os, json
|
import gspread
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from google.oauth2.service_account import Credentials
|
||||||
from config import prompt_settings
|
from config import prompt_settings
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.prompts.schemas import *
|
from app.utils.prompts.schemas import *
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
logger = get_logger("prompt")
|
logger = get_logger("prompt")
|
||||||
|
|
||||||
|
_SCOPES = [
|
||||||
|
"https://www.googleapis.com/auth/spreadsheets.readonly",
|
||||||
|
"https://www.googleapis.com/auth/drive.readonly"
|
||||||
|
]
|
||||||
|
|
||||||
class Prompt():
|
class Prompt():
|
||||||
prompt_template_path : str #프롬프트 경로
|
sheet_name: str
|
||||||
prompt_template : str # fstring 포맷
|
prompt_template: str
|
||||||
prompt_model: str
|
prompt_model: str
|
||||||
|
|
||||||
prompt_input_class = BaseModel # pydantic class 자체를(instance 아님) 변수로 가짐
|
prompt_input_class = BaseModel
|
||||||
prompt_output_class = BaseModel
|
prompt_output_class = BaseModel
|
||||||
|
|
||||||
def __init__(self, prompt_template_path, prompt_input_class, prompt_output_class, prompt_model):
|
def __init__(self, sheet_name, prompt_input_class, prompt_output_class):
|
||||||
self.prompt_template_path = prompt_template_path
|
self.sheet_name = sheet_name
|
||||||
self.prompt_input_class = prompt_input_class
|
self.prompt_input_class = prompt_input_class
|
||||||
self.prompt_output_class = prompt_output_class
|
self.prompt_output_class = prompt_output_class
|
||||||
self.prompt_template = self.read_prompt()
|
self.prompt_template, self.prompt_model = self._read_from_sheets()
|
||||||
self.prompt_model = prompt_model
|
|
||||||
|
def _read_from_sheets(self) -> tuple[str, str]:
|
||||||
|
creds = Credentials.from_service_account_file(
|
||||||
|
prompt_settings.GOOGLE_SERVICE_ACCOUNT_JSON, scopes=_SCOPES
|
||||||
|
)
|
||||||
|
gc = gspread.authorize(creds)
|
||||||
|
ws = gc.open_by_key(prompt_settings.PROMPT_SPREADSHEET).worksheet(self.sheet_name)
|
||||||
|
model = ws.cell(2, 2).value
|
||||||
|
input_text = ws.cell(3, 2).value
|
||||||
|
return input_text, model
|
||||||
|
|
||||||
def _reload_prompt(self):
|
def _reload_prompt(self):
|
||||||
self.prompt_template = self.read_prompt()
|
self.prompt_template, self.prompt_model = self._read_from_sheets()
|
||||||
|
|
||||||
def read_prompt(self) -> tuple[str, dict]:
|
def build_prompt(self, input_data:dict, silent:bool = False) -> str:
|
||||||
with open(self.prompt_template_path, "r") as fp:
|
|
||||||
prompt_template = fp.read()
|
|
||||||
|
|
||||||
return prompt_template
|
|
||||||
|
|
||||||
def build_prompt(self, input_data:dict) -> str:
|
|
||||||
verified_input = self.prompt_input_class(**input_data)
|
verified_input = self.prompt_input_class(**input_data)
|
||||||
build_template = self.prompt_template
|
build_template = self.prompt_template
|
||||||
build_template = build_template.format(**verified_input.model_dump())
|
build_template = build_template.format(**verified_input.model_dump())
|
||||||
|
if not silent:
|
||||||
logger.debug(f"build_template: {build_template}")
|
logger.debug(f"build_template: {build_template}")
|
||||||
logger.debug(f"input_data: {input_data}")
|
logger.debug(f"input_data: {input_data}")
|
||||||
return build_template
|
return build_template
|
||||||
|
|
||||||
marketing_prompt = Prompt(
|
marketing_prompt = Prompt(
|
||||||
prompt_template_path = os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.MARKETING_PROMPT_FILE_NAME),
|
sheet_name="marketing",
|
||||||
prompt_input_class=MarketingPromptInput,
|
prompt_input_class=MarketingPromptInput,
|
||||||
prompt_output_class=MarketingPromptOutput,
|
prompt_output_class=MarketingPromptOutput,
|
||||||
prompt_model = prompt_settings.MARKETING_PROMPT_MODEL
|
|
||||||
)
|
)
|
||||||
|
|
||||||
lyric_prompt = Prompt(
|
lyric_prompt = Prompt(
|
||||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.LYRIC_PROMPT_FILE_NAME),
|
sheet_name="lyric",
|
||||||
prompt_input_class=LyricPromptInput,
|
prompt_input_class=LyricPromptInput,
|
||||||
prompt_output_class=LyricPromptOutput,
|
prompt_output_class=LyricPromptOutput,
|
||||||
prompt_model = prompt_settings.LYRIC_PROMPT_MODEL
|
|
||||||
)
|
)
|
||||||
|
|
||||||
yt_upload_prompt = Prompt(
|
yt_upload_prompt = Prompt(
|
||||||
prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.YOUTUBE_PROMPT_FILE_NAME),
|
sheet_name="yt_upload",
|
||||||
prompt_input_class=YTUploadPromptInput,
|
prompt_input_class=YTUploadPromptInput,
|
||||||
prompt_output_class=YTUploadPromptOutput,
|
prompt_output_class=YTUploadPromptOutput,
|
||||||
prompt_model = prompt_settings.YOUTUBE_PROMPT_MODEL
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
image_autotag_prompt = Prompt(
|
||||||
|
sheet_name="image_tag",
|
||||||
|
prompt_input_class=ImageTagPromptInput,
|
||||||
|
prompt_output_class=ImageTagPromptOutput,
|
||||||
|
)
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def create_dynamic_subtitle_prompt(length: int) -> Prompt:
|
||||||
|
return Prompt(
|
||||||
|
sheet_name="subtitle",
|
||||||
|
prompt_input_class=SubtitlePromptInput,
|
||||||
|
prompt_output_class=SubtitlePromptOutput[length],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def reload_all_prompt():
|
def reload_all_prompt():
|
||||||
marketing_prompt._reload_prompt()
|
marketing_prompt._reload_prompt()
|
||||||
lyric_prompt._reload_prompt()
|
lyric_prompt._reload_prompt()
|
||||||
yt_upload_prompt._reload_prompt()
|
yt_upload_prompt._reload_prompt()
|
||||||
|
image_autotag_prompt._reload_prompt()
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
from .lyric import LyricPromptInput, LyricPromptOutput
|
from .lyric import LyricPromptInput, LyricPromptOutput
|
||||||
from .marketing import MarketingPromptInput, MarketingPromptOutput
|
from .marketing import MarketingPromptInput, MarketingPromptOutput
|
||||||
from .youtube import YTUploadPromptInput, YTUploadPromptOutput
|
from .youtube import YTUploadPromptInput, YTUploadPromptOutput
|
||||||
|
from .image import *
|
||||||
|
from .subtitle import SubtitlePromptInput, SubtitlePromptOutput
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import List, Optional
|
||||||
|
from enum import StrEnum, auto
|
||||||
|
|
||||||
|
class SpaceType(StrEnum):
|
||||||
|
exterior_front = auto()
|
||||||
|
exterior_night = auto()
|
||||||
|
exterior_aerial = auto()
|
||||||
|
exterior_sign = auto()
|
||||||
|
garden = auto()
|
||||||
|
entrance = auto()
|
||||||
|
lobby = auto()
|
||||||
|
reception = auto()
|
||||||
|
hallway = auto()
|
||||||
|
bedroom = auto()
|
||||||
|
livingroom = auto()
|
||||||
|
kitchen = auto()
|
||||||
|
dining = auto()
|
||||||
|
room = auto()
|
||||||
|
bathroom = auto()
|
||||||
|
amenity = auto()
|
||||||
|
view_window = auto()
|
||||||
|
view_ocean = auto()
|
||||||
|
view_city = auto()
|
||||||
|
view_mountain = auto()
|
||||||
|
balcony = auto()
|
||||||
|
cafe = auto()
|
||||||
|
lounge = auto()
|
||||||
|
rooftop = auto()
|
||||||
|
pool = auto()
|
||||||
|
breakfast_hall = auto()
|
||||||
|
spa = auto()
|
||||||
|
fitness = auto()
|
||||||
|
bbq = auto()
|
||||||
|
terrace = auto()
|
||||||
|
glamping = auto()
|
||||||
|
neighborhood = auto()
|
||||||
|
landmark = auto()
|
||||||
|
detail_welcome = auto()
|
||||||
|
detail_beverage = auto()
|
||||||
|
detail_lighting = auto()
|
||||||
|
detail_decor = auto()
|
||||||
|
detail_tableware = auto()
|
||||||
|
|
||||||
|
class Subject(StrEnum):
|
||||||
|
empty_space = auto()
|
||||||
|
exterior_building = auto()
|
||||||
|
architecture_detail = auto()
|
||||||
|
decoration = auto()
|
||||||
|
furniture = auto()
|
||||||
|
food_dish = auto()
|
||||||
|
nature = auto()
|
||||||
|
signage = auto()
|
||||||
|
amenity_item = auto()
|
||||||
|
person = auto()
|
||||||
|
|
||||||
|
class Camera(StrEnum):
|
||||||
|
wide_angle = auto()
|
||||||
|
tight_crop = auto()
|
||||||
|
panoramic = auto()
|
||||||
|
symmetrical = auto()
|
||||||
|
leading_line = auto()
|
||||||
|
golden_hour = auto()
|
||||||
|
night_shot = auto()
|
||||||
|
high_contrast = auto()
|
||||||
|
low_light = auto()
|
||||||
|
drone_shot = auto()
|
||||||
|
has_face = auto()
|
||||||
|
|
||||||
|
class MotionRecommended(StrEnum):
|
||||||
|
static = auto()
|
||||||
|
slow_pan = auto()
|
||||||
|
slow_zoom_in = auto()
|
||||||
|
slow_zoom_out = auto()
|
||||||
|
walkthrough = auto()
|
||||||
|
dolly = auto()
|
||||||
|
|
||||||
|
class NarrativePhase(StrEnum):
|
||||||
|
intro = auto()
|
||||||
|
welcome = auto()
|
||||||
|
core = auto()
|
||||||
|
highlight = auto()
|
||||||
|
support = auto()
|
||||||
|
accent = auto()
|
||||||
|
|
||||||
|
class NarrativePreference(BaseModel):
|
||||||
|
intro: float = Field(..., description="첫인상 — 여기가 어디인가 | 장소의 정체성과 위치를 전달하는 이미지. 영상 첫 1~2초에 어떤 곳인지 즉시 인지시키는 역할. 건물 외관, 간판, 정원 등 **장소 자체를 보여주는** 컷")
|
||||||
|
welcome: float = Field(..., description="진입/환영 — 어떻게 들어가나 | 도착 후 내부로 들어가는 경험을 전달하는 이미지. 공간의 첫 분위기와 동선을 보여줘 들어가고 싶다는 기대감을 만드는 역할. **문을 열고 들어갔을 때 보이는** 컷.")
|
||||||
|
core: float = Field(..., description="핵심 가치 — 무엇을 경험하나 | **고객이 이 장소를 찾는 본질적 이유.** 이 이미지가 없으면 영상 자체가 성립하지 않음. 질문: 이 비즈니스에서 돈을 지불하는 대상이 뭔가? → 그 답이 core.")
|
||||||
|
highlight: float = Field(..., description="차별화 — 뭐가 특별한가 | **같은 카테고리의 경쟁사 대비 이곳을 선택하게 만드는 이유.** core가 왜 왔는가라면, highlight는 왜 **여기**인가에 대한 답.")
|
||||||
|
support: float = Field(..., description="보조/부대 — 그 외에 뭐가 있나 | 핵심은 아니지만 전체 경험을 풍성하게 하는 부가 요소. 없어도 영상은 성립하지만, 있으면 설득력이 올라감. **이것도 있어요** 라고 말하는 컷.")
|
||||||
|
accent: float = Field(..., description="감성/마무리 — 어떤 느낌인가 | 공간의 분위기와 톤을 전달하는 감성 디테일 컷. 직접적 정보 전달보다 **느낌과 무드**를 제공. 영상 사이사이에 삽입되어 완성도를 높이는 역할.")
|
||||||
|
|
||||||
|
# Input 정의
|
||||||
|
class ImageTagPromptInput(BaseModel):
|
||||||
|
img_url : str = Field(..., description="이미지 URL")
|
||||||
|
space_type: list[str] = Field(list(SpaceType), description="공간적 정보를 가지는 태그 리스트")
|
||||||
|
subject: list[str] = Field(list(Subject), description="피사체 정보를 가지는 태그 리스트")
|
||||||
|
camera: list[str] = Field(list(Camera), description="카메라 정보를 가지는 태그 리스트")
|
||||||
|
motion_recommended: list[str] = Field(list(MotionRecommended), description="가능한 카메라 모션 리스트")
|
||||||
|
|
||||||
|
# Output 정의
|
||||||
|
class ImageTagPromptOutput(BaseModel):
|
||||||
|
#ad_avaliable : bool = Field(..., description="광고 영상 사용 가능 이미지 여부")
|
||||||
|
space_type: list[SpaceType] = Field(..., description="공간적 정보를 가지는 태그 리스트")
|
||||||
|
subject: list[Subject] = Field(..., description="피사체 정보를 가지는 태그 리스트")
|
||||||
|
camera: list[Camera] = Field(..., description="카메라 정보를 가지는 태그 리스트")
|
||||||
|
motion_recommended: list[MotionRecommended] = Field(..., description="가능한 카메라 모션 리스트")
|
||||||
|
narrative_preference: NarrativePreference = Field(..., description="이미지의 내러티브 상 점수")
|
||||||
|
|
||||||
|
|
@ -7,13 +7,11 @@ class MarketingPromptInput(BaseModel):
|
||||||
region : str = Field(..., description = "마케팅 대상 지역")
|
region : str = Field(..., description = "마케팅 대상 지역")
|
||||||
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
||||||
|
|
||||||
|
|
||||||
# Output 정의
|
# Output 정의
|
||||||
class BrandIdentity(BaseModel):
|
class BrandIdentity(BaseModel):
|
||||||
location_feature_analysis: str = Field(..., description="입지 특성 분석 (80자 이상 150자 이하)", min_length = 80, max_length = 150) # min/max constraint는 현재 openai json schema 등에서 작동하지 않는다는 보고가 있음.
|
location_feature_analysis: str = Field(..., description="입지 특성 분석 (80자 이상 150자 이하)", min_length = 80, max_length = 150) # min/max constraint는 현재 openai json schema 등에서 작동하지 않는다는 보고가 있음.
|
||||||
concept_scalability: str = Field(..., description="컨셉 확장성 (80자 이상 150자 이하)", min_length = 80, max_length = 150)
|
concept_scalability: str = Field(..., description="컨셉 확장성 (80자 이상 150자 이하)", min_length = 80, max_length = 150)
|
||||||
|
|
||||||
|
|
||||||
class MarketPositioning(BaseModel):
|
class MarketPositioning(BaseModel):
|
||||||
category_definition: str = Field(..., description="마케팅 카테고리")
|
category_definition: str = Field(..., description="마케팅 카테고리")
|
||||||
core_value: str = Field(..., description="마케팅 포지션 핵심 가치")
|
core_value: str = Field(..., description="마케팅 포지션 핵심 가치")
|
||||||
|
|
@ -22,14 +20,12 @@ class AgeRange(BaseModel):
|
||||||
min_age : int = Field(..., ge=0, le=100)
|
min_age : int = Field(..., ge=0, le=100)
|
||||||
max_age : int = Field(..., ge=0, le=100)
|
max_age : int = Field(..., ge=0, le=100)
|
||||||
|
|
||||||
|
|
||||||
class TargetPersona(BaseModel):
|
class TargetPersona(BaseModel):
|
||||||
persona: str = Field(..., description="타겟 페르소나 이름/설명")
|
persona: str = Field(..., description="타겟 페르소나 이름/설명")
|
||||||
age: AgeRange = Field(..., description="타겟 페르소나 나이대")
|
age: AgeRange = Field(..., description="타겟 페르소나 나이대")
|
||||||
favor_target: List[str] = Field(..., description="페르소나의 선호 요소")
|
favor_target: List[str] = Field(..., description="페르소나의 선호 요소")
|
||||||
decision_trigger: str = Field(..., description="구매 결정 트리거")
|
decision_trigger: str = Field(..., description="구매 결정 트리거")
|
||||||
|
|
||||||
|
|
||||||
class SellingPoint(BaseModel):
|
class SellingPoint(BaseModel):
|
||||||
english_category: str = Field(..., description="셀링포인트 카테고리(영문)")
|
english_category: str = Field(..., description="셀링포인트 카테고리(영문)")
|
||||||
korean_category: str = Field(..., description="셀링포인트 카테고리(한글)")
|
korean_category: str = Field(..., description="셀링포인트 카테고리(한글)")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
from pydantic import BaseModel, create_model, Field
|
||||||
|
from typing import List, Optional
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
# Input 정의
|
||||||
|
|
||||||
|
class SubtitlePromptInput(BaseModel):
|
||||||
|
marketing_intelligence : str = Field(..., description="마케팅 인텔리전스 정보")
|
||||||
|
pitching_tag_list_string : str = Field(..., description="필요한 피칭 레이블 리스트 stringify")
|
||||||
|
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
|
||||||
|
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
||||||
|
|
||||||
|
#subtillecars :
|
||||||
|
# Output 정의
|
||||||
|
class PitchingOutput(BaseModel):
|
||||||
|
pitching_tag: str = Field(..., description="피칭 레이블")
|
||||||
|
pitching_data: str = Field(..., description = "피칭 내용물")
|
||||||
|
|
||||||
|
class SubtitlePromptOutput(BaseModel):
|
||||||
|
pitching_results: List[PitchingOutput] = Field(..., description = "피칭 리스트")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@lru_cache()
|
||||||
|
def __class_getitem__(cls, n: int):
|
||||||
|
return create_model(
|
||||||
|
cls.__name__,
|
||||||
|
pitching_results=(
|
||||||
|
List[PitchingOutput],
|
||||||
|
Field(..., min_length=n, max_length=n, description="피칭 리스트")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
|
|
||||||
[Role & Objective]
|
|
||||||
Act as a content marketing expert with strong domain knowledge in the Korean pension / stay-accommodation industry.
|
|
||||||
Your goal is to produce a Marketing Intelligence Report that will be shown to accommodation owners BEFORE any content is generated.
|
|
||||||
The report must clearly explain what makes the property sellable, marketable, and scalable through content.
|
|
||||||
|
|
||||||
[INPUT]
|
|
||||||
- Business Name: {customer_name}
|
|
||||||
- Region: {region}
|
|
||||||
- Region Details: {detail_region_info}
|
|
||||||
|
|
||||||
[Core Analysis Requirements]
|
|
||||||
Analyze the property based on:
|
|
||||||
Location, concept, and nearby environment
|
|
||||||
Target customer behavior and reservation decision factors
|
|
||||||
Include:
|
|
||||||
- Target customer segments & personas
|
|
||||||
- Unique Selling Propositions (USPs)
|
|
||||||
- Competitive landscape (direct & indirect competitors)
|
|
||||||
- Market positioning
|
|
||||||
|
|
||||||
[Key Selling Point Structuring – UI Optimized]
|
|
||||||
From the analysis above, extract the main Key Selling Points using the structure below.
|
|
||||||
Rules:
|
|
||||||
Focus only on factors that directly influence booking decisions
|
|
||||||
Each selling point must be concise and visually scannable
|
|
||||||
Language must be reusable for ads, short-form videos, and listing headlines
|
|
||||||
Avoid full sentences in descriptions; use short selling phrases
|
|
||||||
Do not provide in report
|
|
||||||
|
|
||||||
Output format:
|
|
||||||
[Category]
|
|
||||||
(Tag keyword – 5~8 words, noun-based, UI oval-style)
|
|
||||||
One-line selling phrase (not a full sentence)
|
|
||||||
Limit:
|
|
||||||
5 to 8 Key Selling Points only
|
|
||||||
Do not provide in report
|
|
||||||
|
|
||||||
[Content & Automation Readiness Check]
|
|
||||||
Ensure that:
|
|
||||||
Each tag keyword can directly map to a content theme
|
|
||||||
Each selling phrase can be used as:
|
|
||||||
- Video hook
|
|
||||||
- Image headline
|
|
||||||
- Ad copy snippet
|
|
||||||
|
|
||||||
|
|
||||||
[Tag Generation Rules]
|
|
||||||
- Tags must include **only core keywords that can be directly used for viral video song lyrics**
|
|
||||||
- Each tag should be selected with **search discovery + emotional resonance + reservation conversion** in mind
|
|
||||||
- The number of tags must be **exactly 5**
|
|
||||||
- Tags must be **nouns or short keyword phrases**; full sentences are strictly prohibited
|
|
||||||
- The following categories must be **balanced and all represented**:
|
|
||||||
1) **Location / Local context** (region name, neighborhood, travel context)
|
|
||||||
2) **Accommodation positioning** (emotional stay, private stay, boutique stay, etc.)
|
|
||||||
3) **Emotion / Experience** (healing, rest, one-day escape, memory, etc.)
|
|
||||||
4) **SNS / Viral signals** (Instagram vibes, picture-perfect day, aesthetic travel, etc.)
|
|
||||||
5) **Travel & booking intent** (travel, getaway, stay, relaxation, etc.)
|
|
||||||
|
|
||||||
- If a brand name exists, **at least one tag must include the brand name or a brand-specific expression**
|
|
||||||
- Avoid overly generic keywords (e.g., “hotel”, “travel” alone); **prioritize distinctive, differentiating phrases**
|
|
||||||
- The final output must strictly follow the JSON format below, with no additional text
|
|
||||||
|
|
||||||
"tags": ["Tag1", "Tag2", "Tag3", "Tag4", "Tag5"]
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
|
|
||||||
[ROLE]
|
|
||||||
You are a content marketing expert, brand strategist, and creative songwriter
|
|
||||||
specializing in Korean pension / accommodation businesses.
|
|
||||||
You create lyrics strictly based on Brand & Marketing Intelligence analysis
|
|
||||||
and optimized for viral short-form video content.
|
|
||||||
Marketing Intelligence Report is background reference.
|
|
||||||
|
|
||||||
[INPUT]
|
|
||||||
Business Name: {customer_name}
|
|
||||||
Region: {region}
|
|
||||||
Region Details: {detail_region_info}
|
|
||||||
Brand & Marketing Intelligence Report: {marketing_intelligence_summary}
|
|
||||||
Output Language: {language}
|
|
||||||
|
|
||||||
[INTERNAL ANALYSIS – DO NOT OUTPUT]
|
|
||||||
Internally analyze the following to guide all creative decisions:
|
|
||||||
- Core brand identity and positioning
|
|
||||||
- Emotional hooks derived from selling points
|
|
||||||
- Target audience lifestyle, desires, and travel motivation
|
|
||||||
- Regional atmosphere and symbolic imagery
|
|
||||||
- How the stay converts into “shareable moments”
|
|
||||||
- Which selling points must surface implicitly in lyrics
|
|
||||||
|
|
||||||
[LYRICS & MUSIC CREATION TASK]
|
|
||||||
Based on the Brand & Marketing Intelligence Report for [{customer_name} ({region})], generate:
|
|
||||||
- Original promotional lyrics
|
|
||||||
- Music attributes for AI music generation (Suno-compatible prompt)
|
|
||||||
The output must be designed for VIRAL DIGITAL CONTENT
|
|
||||||
(short-form video, reels, ads).
|
|
||||||
|
|
||||||
[LYRICS REQUIREMENTS]
|
|
||||||
Mandatory Inclusions:
|
|
||||||
- Business name
|
|
||||||
- Region name
|
|
||||||
- Promotion subject
|
|
||||||
- Promotional expressions including:
|
|
||||||
{promotional_expression_example}
|
|
||||||
|
|
||||||
Content Rules:
|
|
||||||
- Lyrics must be emotionally driven, not descriptive listings
|
|
||||||
- Selling points must be IMPLIED, not explained
|
|
||||||
- Must sound natural when sung
|
|
||||||
- Must feel like a lifestyle moment, not an advertisement
|
|
||||||
|
|
||||||
Tone & Style:
|
|
||||||
- Warm, emotional, and aspirational
|
|
||||||
- Trendy, viral-friendly phrasing
|
|
||||||
- Calm but memorable hooks
|
|
||||||
- Suitable for travel / stay-related content
|
|
||||||
|
|
||||||
[SONG & MUSIC ATTRIBUTES – FOR SUNO PROMPT]
|
|
||||||
After the lyrics, generate a concise music prompt including:
|
|
||||||
Song mood (emotional keywords)
|
|
||||||
BPM range
|
|
||||||
Recommended genres (max 2)
|
|
||||||
Key musical motifs or instruments
|
|
||||||
Overall vibe (1 short sentence)
|
|
||||||
|
|
||||||
[CRITICAL LANGUAGE REQUIREMENT – ABSOLUTE RULE]
|
|
||||||
ALL OUTPUT MUST BE 100% WRITTEN IN {language}.
|
|
||||||
no mixed languages
|
|
||||||
All names, places, and expressions must be in {language}
|
|
||||||
Any violation invalidates the entire output
|
|
||||||
|
|
||||||
[OUTPUT RULES – STRICT]
|
|
||||||
{timing_rules}
|
|
||||||
|
|
||||||
No explanations
|
|
||||||
No headings
|
|
||||||
No bullet points
|
|
||||||
No analysis
|
|
||||||
No extra text
|
|
||||||
|
|
||||||
[FAILURE FORMAT]
|
|
||||||
If generation is impossible:
|
|
||||||
ERROR: Brief reason in English
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
# Role
|
|
||||||
Act as a Senior Brand Strategist and Marketing Data Analyst. Your goal is to analyze the provided input data and generate a high-level Marketing Intelligence Report based on the defined output structure.
|
|
||||||
|
|
||||||
# Input Data
|
|
||||||
* **Customer Name:** {customer_name}
|
|
||||||
* **Region:** {region}
|
|
||||||
* **Detail Region Info:** {detail_region_info}
|
|
||||||
|
|
||||||
# Output Rules
|
|
||||||
1. **Language:** All descriptive content must be written in **Korean (한국어)**.
|
|
||||||
2. **Terminology:** Use professional marketing terminology suitable for the hospitality and stay industry.
|
|
||||||
3. **Strict Selection for `selling_points.english_category` and `selling_points.korean_category`:** You must select the value for both category field in `selling_points` strictly from the following English - Korean set allowed list to ensure UI compatibility:
|
|
||||||
* `LOCATION` (입지 환경), `CONCEPT` (브랜드 컨셉), `PRIVACY` (프라이버시), `NIGHT MOOD` (야간 감성), `HEALING` (힐링 요소), `PHOTO SPOT` (포토 스팟), `SHORT GETAWAY` (숏브레이크), `HOSPITALITY` (서비스), `SWIMMING POOL` (수영장), `JACUZZI` (자쿠지), `BBQ PARTY` (바베큐), `FIRE PIT` (불멍), `GARDEN` (정원), `BREAKFAST` (조식), `KIDS FRIENDLY` (키즈 케어), `PET FRIENDLY` (애견 동반), `OCEAN VIEW` (오션뷰), `PRIVATE POOL` (개별 수영장), `OCEAN VIEW`, `PRIVATE POOL`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Instruction per Output Field (Mapping Logic)
|
|
||||||
|
|
||||||
### 1. brand_identity
|
|
||||||
* **`location_feature_analysis`**: Analyze the marketing advantages of the given `{region}` and `{detail_region_info}`. Explain why this specific location is attractive to travelers. summarize in 1-2 sentences. (e.g., proximity to nature, accessibility from Seoul, or unique local atmosphere).
|
|
||||||
* **`concept_scalability`**: Based on `{customer_name}`, analyze how the brand's core concept can expand into a total customer experience or additional services. summarize in 1-2 sentences.
|
|
||||||
|
|
||||||
### 2. market_positioning
|
|
||||||
* **`category_definition`**: Define a sharp, niche market category for this business (e.g., "Private Forest Cabin" or "Luxury Kids Pool Villa").
|
|
||||||
* **`core_value`**: Identify the single most compelling emotional or functional value that distinguishes `{customer_name}` from competitors.
|
|
||||||
|
|
||||||
### 3. target_persona
|
|
||||||
Generate a list of personas based on the following:
|
|
||||||
* **`persona`**: Provide a descriptive name and profile for the target group.
|
|
||||||
* **`age`**: Set `min_age` and `max_age` (Integer 0-100) that accurately reflects the segment.
|
|
||||||
* **`favor_target`**: List specific elements or vibes this persona prefers (e.g., "Minimalist interior", "Pet-friendly facilities").
|
|
||||||
* **`decision_trigger`**: Identify the specific "Hook" or facility that leads this persona to finalize a booking.
|
|
||||||
|
|
||||||
### 4. selling_points
|
|
||||||
Generate 5-8 selling points:
|
|
||||||
* **`english_category`**: Strictly use one keyword from the English allowed list provided in the Output Rules.
|
|
||||||
* **`korean category`**: Strictly use one keyword from the Korean allowed list provided in the Output Rules . It must be matched with english category.
|
|
||||||
* **`description`**: A short, punchy marketing phrase in Korean (15~30 characters).
|
|
||||||
* **`score`**: An integer (0-100) representing the strength of this feature based on the brand's potential.
|
|
||||||
|
|
||||||
### 5. target_keywords
|
|
||||||
* **`target_keywords`**: Provide a list of 10 highly relevant marketing keywords or hashtags for search engine optimization and social media targeting. Do not insert # in front of hashtag.
|
|
||||||
|
|
@ -0,0 +1,233 @@
|
||||||
|
# System Prompt: 숙박 숏폼 자막 생성 (OpenAI Optimized)
|
||||||
|
|
||||||
|
You are a subtitle copywriter for hospitality short-form videos. You generate subtitle text AND layer names from marketing JSON data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### RULES
|
||||||
|
|
||||||
|
1. NEVER copy JSON verbatim. ALWAYS rewrite into video-optimized copy.
|
||||||
|
2. NEVER invent facts not in the data. You MAY freely transform expressions.
|
||||||
|
3. Each scene = 1 subtitle + 1 keyword (a "Pair"). Same pair_id for both.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### LAYER NAME FORMAT (5-criteria)
|
||||||
|
|
||||||
|
```
|
||||||
|
(track_role)-(narrative_phase)-(content_type)-(tone)-(pair_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Criteria separator: hyphen `-`
|
||||||
|
- Multi-word value: underscore `_`
|
||||||
|
- pair_id: 3-digit zero-padded (`001`~`999`)
|
||||||
|
|
||||||
|
Example: `subtitle-intro-hook_claim-aspirational-001`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TAG VALUES
|
||||||
|
|
||||||
|
**track_role**: `subtitle` | `keyword`
|
||||||
|
|
||||||
|
**narrative_phase** (= emotion goal):
|
||||||
|
- `intro` → Curiosity (stop the scroll)
|
||||||
|
- `welcome` → Warmth
|
||||||
|
- `core` → Trust
|
||||||
|
- `highlight` → Desire (peak moment)
|
||||||
|
- `support` → Discovery
|
||||||
|
- `accent` → Belonging
|
||||||
|
- `cta` → Action
|
||||||
|
|
||||||
|
**content_type** → source mapping:
|
||||||
|
- `hook_claim` ← selling_points[0] or core_value
|
||||||
|
- `space_feature` ← selling_points[].description
|
||||||
|
- `emotion_cue` ← same source, sensory rewrite
|
||||||
|
- `brand_name` ← store_name (verbatim OK)
|
||||||
|
- `brand_address` ← detail_region_info (verbatim OK)
|
||||||
|
- `lifestyle_fit` ← target_persona[].favor_target
|
||||||
|
- `local_info` ← location_feature_analysis
|
||||||
|
- `target_tag` ← target_keywords[] as hashtags
|
||||||
|
- `availability` ← fixed: "지금 예약 가능"
|
||||||
|
- `cta_action` ← fixed: "예약하러 가기"
|
||||||
|
|
||||||
|
**tone**: `sensory` | `factual` | `empathic` | `aspirational` | `social_proof` | `urgent`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SCENE STRUCTURE
|
||||||
|
|
||||||
|
**Anchors (FIXED — never remove):**
|
||||||
|
|
||||||
|
| Position | Phase | subtitle | keyword |
|
||||||
|
|---|---|---|---|
|
||||||
|
| First | intro | hook_claim | brand_name |
|
||||||
|
| Last-3 | support | brand_address | brand_name |
|
||||||
|
| Last-2 | accent | target_tag | lifestyle_fit |
|
||||||
|
| Last | cta | availability | cta_action |
|
||||||
|
|
||||||
|
**Middle (FLEXIBLE — fill by selling_points score desc):**
|
||||||
|
|
||||||
|
| Phase | subtitle | keyword |
|
||||||
|
|---|---|---|
|
||||||
|
| welcome | emotion_cue | space_feature |
|
||||||
|
| core | space_feature | emotion_cue |
|
||||||
|
| highlight | space_feature | emotion_cue |
|
||||||
|
| support(mid) | local_info | lifestyle_fit |
|
||||||
|
|
||||||
|
Default: 7 scenes. Fewer scenes → remove flexible slots only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TEXT SPECS
|
||||||
|
|
||||||
|
**subtitle**: 8~18 chars. Sentence fragment, conversational.
|
||||||
|
**keyword**: 2~6 chars. MUST follow Korean word-formation rules below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### KEYWORD RULES (한국어 조어법 기반)
|
||||||
|
|
||||||
|
Keywords MUST follow one of these **permitted Korean patterns**. Any keyword that does not match a pattern below is INVALID.
|
||||||
|
|
||||||
|
#### Pattern 1: 관형형 + 명사 (Attributive + Noun) — 가장 자연스러운 패턴
|
||||||
|
한국어는 수식어가 앞, 피수식어가 뒤. 형용사의 관형형(~ㄴ/~한/~는/~운)을 명사 앞에 붙인다.
|
||||||
|
|
||||||
|
| Structure | GOOD | BAD (역순/비문) |
|
||||||
|
|---|---|---|
|
||||||
|
| 형용사 관형형 + 명사 | 고요한 숲, 깊은 쉼, 온전한 쉼 | ~~숲고요~~, ~~쉼깊은~~ |
|
||||||
|
| 형용사 관형형 + 명사 | 따뜻한 독채, 느린 하루 | ~~독채따뜻~~, ~~하루느린~~ |
|
||||||
|
| 동사 관형형 + 명사 | 쉬어가는 숲, 머무는 시간 | ~~숲쉬어가는~~ |
|
||||||
|
|
||||||
|
#### Pattern 2: 기존 대중화 합성어 ONLY (Established Trending Compound)
|
||||||
|
이미 SNS·미디어에서 대중화된 합성어만 허용. 임의 신조어 생성 금지.
|
||||||
|
|
||||||
|
| GOOD (대중화 확인됨) | Origin | BAD (임의 생성) |
|
||||||
|
|---|---|---|
|
||||||
|
| 숲멍 | 숲+멍때리기 (불멍, 물멍 시리즈) | ~~숲고요~~, ~~숲힐~~ |
|
||||||
|
| 댕캉스 | 댕댕이+바캉스 (여행업계 통용) | ~~댕쉼~~, ~~댕여행~~ |
|
||||||
|
| 꿀잠 / 꿀쉼 | 꿀+잠/쉼 (일상어 정착) | ~~꿀독채~~, ~~꿀숲~~ |
|
||||||
|
| 집콕 / 숲콕 | 집+콕 → 숲+콕 (변형 허용) | ~~계곡콕~~ |
|
||||||
|
| 주말러 | 주말+~러 (~러 접미사 정착) | ~~평일러~~ |
|
||||||
|
|
||||||
|
> **판별 기준**: "이 단어를 네이버/인스타에서 검색하면 결과가 나오는가?" YES → 허용, NO → 금지
|
||||||
|
|
||||||
|
#### Pattern 3: 명사 + 명사 (Natural Compound Noun)
|
||||||
|
한국어 복합명사 규칙을 따르는 결합만 허용. 앞 명사가 뒷 명사를 수식하는 관계여야 한다.
|
||||||
|
|
||||||
|
| Structure | GOOD | BAD (부자연스러운 결합) |
|
||||||
|
|---|---|---|
|
||||||
|
| 장소 + 유형 | 숲속독채, 계곡펜션 | ~~햇살독채~~ (햇살은 장소가 아님) |
|
||||||
|
| 대상 + 활동 | 반려견산책, 가족피크닉 | ~~견주피크닉~~ (견주가 피크닉하는 건 어색) |
|
||||||
|
| 시간 + 활동 | 주말탈출, 새벽산책 | ~~자연독채~~ (자연은 시간/방식이 아님) |
|
||||||
|
|
||||||
|
#### Pattern 4: 해시태그형 (#키워드)
|
||||||
|
accent(target_tag) 씬에서만 사용. 기존 검색 키워드를 # 붙여서 사용.
|
||||||
|
|
||||||
|
| GOOD | BAD |
|
||||||
|
|---|---|
|
||||||
|
| #프라이빗독채, #홍천여행 | #숲고요, #감성쩌는 (검색량 없음) |
|
||||||
|
|
||||||
|
#### Pattern 5: 감각/상태 명사 (단독 사용 가능한 것만)
|
||||||
|
그 자체로 의미가 완결되는 감각·상태 명사만 단독 사용 허용.
|
||||||
|
|
||||||
|
| GOOD (단독 의미 완결) | BAD (단독으로 의미 불완전) |
|
||||||
|
|---|---|
|
||||||
|
| 고요, 여유, 쉼, 온기 | ~~감성~~, ~~자연~~, ~~힐링~~ (너무 모호) |
|
||||||
|
| 숲멍, 꿀쉼 | ~~좋은쉼~~, ~~편안함~~ (형용사 포함 시 Pattern 1 사용) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### KEYWORD VALIDATION CHECKLIST (생성 후 자가 검증)
|
||||||
|
|
||||||
|
Every keyword MUST pass ALL of these:
|
||||||
|
|
||||||
|
- [ ] 한국어 어순이 자연스러운가? (수식어→피수식어 순서)
|
||||||
|
- [ ] 소리 내어 읽었을 때 어색하지 않은가?
|
||||||
|
- [ ] 네이버/인스타에서 검색하면 실제 결과가 나올 법한 표현인가?
|
||||||
|
- [ ] 허용된 5개 Pattern 중 하나에 해당하는가?
|
||||||
|
- [ ] 이전 씬 keyword와 동일한 Pattern을 연속 사용하지 않았는가?
|
||||||
|
- [ ] 금지 표현 사전에 해당하지 않는가?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### EXPRESSION DICTIONARY
|
||||||
|
|
||||||
|
**SCAN BEFORE WRITING.** If JSON contains these → MUST replace:
|
||||||
|
|
||||||
|
| Forbidden | → Use Instead |
|
||||||
|
|---|---|
|
||||||
|
| 눈치 없는/없이 | 눈치 안 보는 · 프라이빗한 · 온전한 · 마음 편히 |
|
||||||
|
| 감성 쩌는/쩌이 | 감성 가득한 · 감성이 머무는 |
|
||||||
|
| 가성비 | 합리적인 · 가치 있는 |
|
||||||
|
| 힐링되는 | 회복되는 · 쉬어가는 · 숨 쉬는 |
|
||||||
|
| 인스타감성 | 감성 스팟 · 기록하고 싶은 |
|
||||||
|
| 혜자 | 풍성한 · 넉넉한 |
|
||||||
|
|
||||||
|
**ALWAYS FORBIDDEN**: 저렴한, 싼, 그냥, 보통, 무난한, 평범한, 쩌는, 쩔어, 개(접두사), 존맛, 핵, 인스타, 유튜브, 틱톡
|
||||||
|
|
||||||
|
**SYNONYM ROTATION**: Same Korean word max 2 scenes. Rotate:
|
||||||
|
- 프라이빗 계열: 온전한 · 오롯한 · 나만의 · 독채 · 단독
|
||||||
|
- 자연 계열: 숲속 · 초록 · 산림 · 계곡
|
||||||
|
- 쉼 계열: 쉼 · 여유 · 느린 하루 · 머무름 · 숨고르기
|
||||||
|
- 반려견: 댕댕이(max 1회, intro/accent만) · 반려견 · 우리 강아지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TRANSFORM RULES BY CONTENT_TYPE
|
||||||
|
|
||||||
|
**hook_claim** (intro only):
|
||||||
|
- Format: question OR exclamation OR provocation. Pick ONE.
|
||||||
|
- FORBIDDEN: brand name, generic greetings
|
||||||
|
- `"반려견과 눈치 없는 힐링"` → BAD: 그대로 복사 → GOOD: "댕댕이가 먼저 뛰어간 숲"
|
||||||
|
|
||||||
|
**space_feature** (core/highlight):
|
||||||
|
- ONE selling point per scene
|
||||||
|
- NEVER use korean_category directly
|
||||||
|
- Viewer must imagine themselves there
|
||||||
|
- `"홍천 자연 속 조용한 쉼"` → BAD: "입지 환경이 좋은 곳" → GOOD: "계곡 소리만 들리는 독채"
|
||||||
|
|
||||||
|
**emotion_cue** (welcome/core/highlight):
|
||||||
|
- Senses: smell, sound, touch, temperature, light
|
||||||
|
- Poetic fragments, not full sentences
|
||||||
|
- `"감성 쩌이 완성되는 공간"` → GOOD: "햇살이 내려앉는 테라스"
|
||||||
|
|
||||||
|
**lifestyle_fit** (accent/support):
|
||||||
|
- Address target directly in their language
|
||||||
|
- `persona: "서울·경기 주말러"` → GOOD: "이번 주말, 댕댕이랑 어디 가지?"
|
||||||
|
|
||||||
|
**local_info** (support):
|
||||||
|
- Accessibility or charm, NOT administrative address
|
||||||
|
- GOOD: "서울에서 1시간 반, 홍천 숲속" / BAD: "강원 홍천군 화촌면"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PACING
|
||||||
|
|
||||||
|
```
|
||||||
|
intro(8~12) → welcome(12~18) → core(alternate 8~12 ↔ 12~18) → highlight(8~14) → support(12~18) → accent(variable) → cta(12~16)
|
||||||
|
```
|
||||||
|
|
||||||
|
**RULE: No 3+ consecutive scenes in same char-count range.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Keyword pattern analysis:
|
||||||
|
- "스테이펫" → brand_name verbatim (허용)
|
||||||
|
- "고요한 숲" → Pattern 1: 관형형+명사 (형용사 관형형 "고요한" + 명사 "숲")
|
||||||
|
- "깊은 쉼" → Pattern 1: 관형형+명사 (형용사 관형형 "깊은" + 명사 "쉼")
|
||||||
|
- "숲멍" → Pattern 2: 기존 대중화 합성어 (불멍·물멍·숲멍 시리즈)
|
||||||
|
- "댕캉스" → Pattern 2: 기존 대중화 합성어 (댕댕이+바캉스, 여행업계 통용)
|
||||||
|
- "예약하기" → Pattern 5: 의미 완결 동사 명사형
|
||||||
|
|
||||||
|
|
||||||
|
# 입력
|
||||||
|
**입력 1: 레이어 이름 리스트**
|
||||||
|
{pitching_tag_list_string}
|
||||||
|
|
||||||
|
**입력 2: 마케팅 인텔리전스 JSON**
|
||||||
|
{marketing_intelligence}
|
||||||
|
|
||||||
|
**입력 3: 비즈니스 정보 **
|
||||||
|
Business Name: {customer_name}
|
||||||
|
Region Details: {detail_region_info}
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
[ROLE]
|
|
||||||
You are a YouTube SEO/AEO content strategist specialized in local stay, pension, and accommodation brands in Korea.
|
|
||||||
You create search-optimized, emotionally appealing, and action-driving titles and descriptions based on Brand & Marketing Intelligence.
|
|
||||||
|
|
||||||
Your goal is to:
|
|
||||||
|
|
||||||
Increase search visibility
|
|
||||||
Improve click-through rate
|
|
||||||
Reflect the brand’s positioning
|
|
||||||
Trigger emotional interest
|
|
||||||
Encourage booking or inquiry actions through subtle CTA
|
|
||||||
|
|
||||||
|
|
||||||
[INPUT]
|
|
||||||
Business Name: {customer_name}
|
|
||||||
Region Details: {detail_region_info}
|
|
||||||
Brand & Marketing Intelligence Report: {marketing_intelligence_summary}
|
|
||||||
Target Keywords: {target_keywords}
|
|
||||||
Output Language: {language}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[INTERNAL ANALYSIS – DO NOT OUTPUT]
|
|
||||||
Analyze the following from the marketing intelligence:
|
|
||||||
|
|
||||||
Core brand concept
|
|
||||||
Main emotional promise
|
|
||||||
Primary target persona
|
|
||||||
Top 2–3 USP signals
|
|
||||||
Stay context (date, healing, local trip, etc.)
|
|
||||||
Search intent behind the target keywords
|
|
||||||
Main booking trigger
|
|
||||||
Emotional moment that would make the viewer want to stay
|
|
||||||
Use these to guide:
|
|
||||||
|
|
||||||
Title tone
|
|
||||||
Opening CTA line
|
|
||||||
Emotional hook in the first sentences
|
|
||||||
|
|
||||||
|
|
||||||
[TITLE GENERATION RULES]
|
|
||||||
|
|
||||||
The title must:
|
|
||||||
|
|
||||||
Include the business name or region when natural
|
|
||||||
Always wrap the business name in quotation marks
|
|
||||||
Example: “스테이 머뭄”
|
|
||||||
Include 1–2 high-intent keywords
|
|
||||||
Reflect emotional positioning
|
|
||||||
Suggest a desirable stay moment
|
|
||||||
Sound like a natural YouTube title, not an advertisement
|
|
||||||
Length rules:
|
|
||||||
|
|
||||||
Hard limit: 100 characters
|
|
||||||
Target range: 45–65 characters
|
|
||||||
Place primary keyword in the first half
|
|
||||||
Avoid:
|
|
||||||
|
|
||||||
ALL CAPS
|
|
||||||
Excessive symbols
|
|
||||||
Price or promotion language
|
|
||||||
Hard-sell expressions
|
|
||||||
|
|
||||||
|
|
||||||
[DESCRIPTION GENERATION RULES]
|
|
||||||
|
|
||||||
Character rules:
|
|
||||||
|
|
||||||
Maximum length: 1,000 characters
|
|
||||||
Critical information must appear within the first 150 characters
|
|
||||||
Language style rules (mandatory):
|
|
||||||
|
|
||||||
Use polite Korean honorific style
|
|
||||||
Replace “있나요?” with “있으신가요?”
|
|
||||||
Do not start sentences with “이곳은”
|
|
||||||
Replace “선택이 됩니다” with “추천 드립니다”
|
|
||||||
Always wrap the business name in quotation marks
|
|
||||||
Example: “스테이 머뭄”
|
|
||||||
Avoid vague location words like “근대거리” alone
|
|
||||||
Use specific phrasing such as:
|
|
||||||
“군산 근대역사문화거리 일대”
|
|
||||||
Structure:
|
|
||||||
|
|
||||||
Opening CTA (first line)
|
|
||||||
Must be a question or gentle suggestion
|
|
||||||
Must use honorific tone
|
|
||||||
Example:
|
|
||||||
“조용히 쉴 수 있는 군산숙소를 찾고 있으신가요?”
|
|
||||||
Core Stay Introduction (within first 150 characters total)
|
|
||||||
Mention business name with quotation marks
|
|
||||||
Mention region
|
|
||||||
Include main keyword
|
|
||||||
Briefly describe the stay experience
|
|
||||||
Brand Experience
|
|
||||||
Core value and emotional promise
|
|
||||||
Based on marketing intelligence positioning
|
|
||||||
Key Highlights (3–4 short lines)
|
|
||||||
Derived from USP signals
|
|
||||||
Natural sentences
|
|
||||||
Focus on booking-trigger moments
|
|
||||||
Local Context
|
|
||||||
Mention nearby experiences
|
|
||||||
Use specific local references
|
|
||||||
Example:
|
|
||||||
“군산 근대역사문화거리 일대 산책이나 로컬 카페 투어”
|
|
||||||
Soft Closing Line
|
|
||||||
One gentle, non-salesy closing sentence
|
|
||||||
Must end with a recommendation tone
|
|
||||||
Example:
|
|
||||||
“군산에서 조용한 시간을 보내고 싶다면 ‘스테이 머뭄’을 추천 드립니다.”
|
|
||||||
|
|
||||||
|
|
||||||
[SEO & AEO RULES]
|
|
||||||
|
|
||||||
Naturally integrate 3–5 keywords from {target_keywords}
|
|
||||||
Avoid keyword stuffing
|
|
||||||
Use conversational, search-like phrasing
|
|
||||||
Optimize for:
|
|
||||||
YouTube search
|
|
||||||
Google video results
|
|
||||||
AI answer summaries
|
|
||||||
Keywords should appear in:
|
|
||||||
|
|
||||||
Title (1–2)
|
|
||||||
First 150 characters of description
|
|
||||||
Highlight or context sections
|
|
||||||
|
|
||||||
|
|
||||||
[LANGUAGE RULE]
|
|
||||||
|
|
||||||
All output must be written entirely in {language}.
|
|
||||||
No mixed languages.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[OUTPUT FORMAT – STRICT]
|
|
||||||
|
|
||||||
title:
|
|
||||||
description:
|
|
||||||
|
|
||||||
No explanations.
|
|
||||||
No headings.
|
|
||||||
No extra text.
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import copy
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
from typing import Literal, Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
||||||
|
from app.utils.prompts.schemas import *
|
||||||
|
from app.utils.prompts.prompts import *
|
||||||
|
|
||||||
|
class SubtitleContentsGenerator():
|
||||||
|
def __init__(self):
|
||||||
|
self.chatgpt_service = ChatgptService()
|
||||||
|
|
||||||
|
async def generate_subtitle_contents(self, marketing_intelligence : dict[str, Any], pitching_label_list : list[Any], customer_name : str, detail_region_info : str) -> SubtitlePromptOutput:
|
||||||
|
dynamic_subtitle_prompt = create_dynamic_subtitle_prompt(len(pitching_label_list))
|
||||||
|
pitching_label_string = "\n".join(pitching_label_list)
|
||||||
|
marketing_intel_string = json.dumps(marketing_intelligence, ensure_ascii=False)
|
||||||
|
input_data = {
|
||||||
|
"marketing_intelligence" : marketing_intel_string ,
|
||||||
|
"pitching_tag_list_string" : pitching_label_string,
|
||||||
|
"customer_name" : customer_name,
|
||||||
|
"detail_region_info" : detail_region_info,
|
||||||
|
}
|
||||||
|
output_data = await self.chatgpt_service.generate_structured_output(dynamic_subtitle_prompt, input_data)
|
||||||
|
return output_data
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -14,6 +14,8 @@ Video API Router
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||||
|
|
@ -23,10 +25,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.database.session import get_session
|
from app.database.session import get_session
|
||||||
from app.user.dependencies.auth import get_current_user
|
from app.user.dependencies.auth import get_current_user
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.home.models import Image, Project
|
from app.home.models import Image, Project, MarketingIntel, ImageTag
|
||||||
from app.lyric.models import Lyric
|
from app.lyric.models import Lyric
|
||||||
from app.song.models import Song, SongTimestamp
|
from app.song.models import Song, SongTimestamp
|
||||||
from app.utils.creatomate import CreatomateService
|
from app.utils.creatomate import CreatomateService
|
||||||
|
from app.utils.subtitles import SubtitleContentsGenerator
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.video.models import Video
|
from app.video.models import Video
|
||||||
from app.video.schemas.video_schema import (
|
from app.video.schemas.video_schema import (
|
||||||
|
|
@ -36,6 +39,7 @@ from app.video.schemas.video_schema import (
|
||||||
VideoRenderData,
|
VideoRenderData,
|
||||||
)
|
)
|
||||||
from app.video.worker.video_task import download_and_upload_video_to_blob
|
from app.video.worker.video_task import download_and_upload_video_to_blob
|
||||||
|
from app.video.services.video import get_image_tags_by_task_id
|
||||||
|
|
||||||
from config import creatomate_settings
|
from config import creatomate_settings
|
||||||
|
|
||||||
|
|
@ -144,6 +148,34 @@ async def generate_video(
|
||||||
image_urls: list[str] = []
|
image_urls: list[str] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
subtitle_done = False
|
||||||
|
count = 0
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
project_result = await session.execute(
|
||||||
|
select(Project)
|
||||||
|
.where(Project.task_id == task_id)
|
||||||
|
.order_by(Project.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
project = project_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
while not subtitle_done:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
logger.info(f"[generate_video] Checking subtitle- task_id: {task_id}, count : {count}")
|
||||||
|
marketing_result = await session.execute(
|
||||||
|
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
||||||
|
)
|
||||||
|
marketing_intelligence = marketing_result.scalar_one_or_none()
|
||||||
|
subtitle_done = bool(marketing_intelligence.subtitle)
|
||||||
|
if subtitle_done:
|
||||||
|
logger.info(f"[generate_video] Check subtitle done task_id: {task_id}")
|
||||||
|
break
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
if count > 60 :
|
||||||
|
raise Exception("subtitle 결과 생성 실패")
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
|
||||||
# 세션을 명시적으로 열고 DB 작업 후 바로 닫음
|
# 세션을 명시적으로 열고 DB 작업 후 바로 닫음
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
# ===== 순차 쿼리 실행: Project, Lyric, Song, Image =====
|
# ===== 순차 쿼리 실행: Project, Lyric, Song, Image =====
|
||||||
|
|
@ -198,6 +230,12 @@ async def generate_video(
|
||||||
)
|
)
|
||||||
project_id = project.id
|
project_id = project.id
|
||||||
store_address = project.detail_region_info
|
store_address = project.detail_region_info
|
||||||
|
# customer_name = project.store_name
|
||||||
|
|
||||||
|
marketing_result = await session.execute(
|
||||||
|
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
||||||
|
)
|
||||||
|
marketing_intelligence = marketing_result.scalar_one_or_none()
|
||||||
|
|
||||||
# ===== 결과 처리: Lyric =====
|
# ===== 결과 처리: Lyric =====
|
||||||
lyric = lyric_result.scalar_one_or_none()
|
lyric = lyric_result.scalar_one_or_none()
|
||||||
|
|
@ -287,51 +325,67 @@ async def generate_video(
|
||||||
# 2단계: 외부 API 호출 (세션 사용 안함 - 커넥션 풀 점유 없음)
|
# 2단계: 외부 API 호출 (세션 사용 안함 - 커넥션 풀 점유 없음)
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
stage2_start = time.perf_counter()
|
stage2_start = time.perf_counter()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}"
|
f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}"
|
||||||
)
|
)
|
||||||
creatomate_service = CreatomateService(
|
creatomate_service = CreatomateService(
|
||||||
orientation=orientation,
|
orientation=orientation
|
||||||
target_duration=song_duration,
|
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[generate_video] Using template_id: {creatomate_service.template_id}, duration: {creatomate_service.target_duration} (song duration: {song_duration})"
|
f"[generate_video] Using template_id: {creatomate_service.template_id}, (song duration: {song_duration})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 6-1. 템플릿 조회 (비동기)
|
# 6-1. 템플릿 조회 (비동기)
|
||||||
template = await creatomate_service.get_one_template_data_async(
|
template = await creatomate_service.get_one_template_data(
|
||||||
creatomate_service.template_id
|
creatomate_service.template_id
|
||||||
)
|
)
|
||||||
logger.debug(f"[generate_video] Template fetched - task_id: {task_id}")
|
logger.debug(f"[generate_video] Template fetched - task_id: {task_id}")
|
||||||
|
|
||||||
# 6-2. elements에서 리소스 매핑 생성
|
# 6-2. elements에서 리소스 매핑 생성
|
||||||
modifications = creatomate_service.elements_connect_resource_blackbox(
|
# modifications = creatomate_service.elements_connect_resource_blackbox(
|
||||||
elements=template["source"]["elements"],
|
# elements=template["source"]["elements"],
|
||||||
image_url_list=image_urls,
|
# image_url_list=image_urls,
|
||||||
lyric=lyrics,
|
# music_url=music_url,
|
||||||
|
# address=store_address
|
||||||
|
taged_image_list = await get_image_tags_by_task_id(task_id)
|
||||||
|
min_image_num = creatomate_service.counting_component(
|
||||||
|
template = template,
|
||||||
|
target_template_type = "image"
|
||||||
|
)
|
||||||
|
duplicate = bool(len(taged_image_list) < min_image_num)
|
||||||
|
logger.info(f"[generate_video] Duplicate : {duplicate} | length of taged_image {len(taged_image_list)}, min_len {min_image_num},- task_id: {task_id}")
|
||||||
|
modifications = creatomate_service.template_matching_taged_image(
|
||||||
|
template = template,
|
||||||
|
taged_image_list = taged_image_list,
|
||||||
music_url = music_url,
|
music_url = music_url,
|
||||||
address=store_address
|
address = store_address,
|
||||||
|
duplicate = duplicate,
|
||||||
)
|
)
|
||||||
logger.debug(f"[generate_video] Modifications created - task_id: {task_id}")
|
logger.debug(f"[generate_video] Modifications created - task_id: {task_id}")
|
||||||
|
|
||||||
|
subtitle_modifications = marketing_intelligence.subtitle
|
||||||
|
|
||||||
|
modifications.update(subtitle_modifications)
|
||||||
# 6-3. elements 수정
|
# 6-3. elements 수정
|
||||||
new_elements = creatomate_service.modify_element(
|
new_elements = creatomate_service.modify_element(
|
||||||
template["source"]["elements"],
|
template["source"]["elements"],
|
||||||
modifications,
|
modifications,
|
||||||
)
|
)
|
||||||
template["source"]["elements"] = new_elements
|
template["source"]["elements"] = new_elements
|
||||||
|
|
||||||
logger.debug(f"[generate_video] Elements modified - task_id: {task_id}")
|
logger.debug(f"[generate_video] Elements modified - task_id: {task_id}")
|
||||||
|
|
||||||
|
|
||||||
# 6-4. duration 확장
|
# 6-4. duration 확장
|
||||||
final_template = creatomate_service.extend_template_duration(
|
final_template = creatomate_service.extend_template_duration(
|
||||||
template,
|
template,
|
||||||
creatomate_service.target_duration,
|
song_duration,
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.debug(f"[generate_video] Duration extended - task_id: {task_id}")
|
||||||
|
|
||||||
song_timestamp_result = await session.execute(
|
song_timestamp_result = await session.execute(
|
||||||
select(SongTimestamp).where(
|
select(SongTimestamp).where(
|
||||||
SongTimestamp.suno_audio_id == song.suno_audio_id
|
SongTimestamp.suno_audio_id == song.suno_audio_id
|
||||||
|
|
@ -339,13 +393,10 @@ async def generate_video(
|
||||||
)
|
)
|
||||||
song_timestamp_list = song_timestamp_result.scalars().all()
|
song_timestamp_list = song_timestamp_result.scalars().all()
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(f"[generate_video] song_timestamp_list count: {len(song_timestamp_list)}")
|
||||||
f"[generate_video] song_timestamp_list count: {len(song_timestamp_list)}"
|
|
||||||
)
|
|
||||||
for i, ts in enumerate(song_timestamp_list):
|
for i, ts in enumerate(song_timestamp_list):
|
||||||
logger.debug(
|
logger.debug(f"[generate_video] timestamp[{i}]: lyric_line={ts.lyric_line}, start_time={ts.start_time}, end_time={ts.end_time}")
|
||||||
f"[generate_video] timestamp[{i}]: lyric_line={ts.lyric_line}, start_time={ts.start_time}, end_time={ts.end_time}"
|
|
||||||
)
|
|
||||||
|
|
||||||
match lyric_language:
|
match lyric_language:
|
||||||
case "English" :
|
case "English" :
|
||||||
|
|
@ -355,6 +406,7 @@ async def generate_video(
|
||||||
lyric_font = "Noto Sans"
|
lyric_font = "Noto Sans"
|
||||||
|
|
||||||
# LYRIC AUTO 결정부
|
# LYRIC AUTO 결정부
|
||||||
|
if (creatomate_settings.LYRIC_SUBTITLE):
|
||||||
if (creatomate_settings.DEBUG_AUTO_LYRIC):
|
if (creatomate_settings.DEBUG_AUTO_LYRIC):
|
||||||
auto_text_template = creatomate_service.get_auto_text_template()
|
auto_text_template = creatomate_service.get_auto_text_template()
|
||||||
final_template["source"]["elements"].append(creatomate_service.auto_lyric(auto_text_template))
|
final_template["source"]["elements"].append(creatomate_service.auto_lyric(auto_text_template))
|
||||||
|
|
@ -374,14 +426,12 @@ async def generate_video(
|
||||||
# logger.debug(
|
# logger.debug(
|
||||||
# f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
|
# f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
|
||||||
# )
|
# )
|
||||||
|
|
||||||
# 6-5. 커스텀 렌더링 요청 (비동기)
|
# 6-5. 커스텀 렌더링 요청 (비동기)
|
||||||
render_response = await creatomate_service.make_creatomate_custom_call_async(
|
render_response = await creatomate_service.make_creatomate_custom_call(
|
||||||
final_template["source"],
|
final_template["source"],
|
||||||
)
|
)
|
||||||
logger.debug(
|
|
||||||
f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}"
|
logger.debug(f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}")
|
||||||
)
|
|
||||||
|
|
||||||
# 렌더 ID 추출
|
# 렌더 ID 추출
|
||||||
if isinstance(render_response, list) and len(render_response) > 0:
|
if isinstance(render_response, list) and len(render_response) > 0:
|
||||||
|
|
@ -402,6 +452,8 @@ async def generate_video(
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[generate_video] Creatomate API EXCEPTION - task_id: {task_id}, error: {e}"
|
f"[generate_video] Creatomate API EXCEPTION - task_id: {task_id}, error: {e}"
|
||||||
)
|
)
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
# 외부 API 실패 시 Video 상태를 failed로 업데이트
|
# 외부 API 실패 시 Video 상태를 failed로 업데이트
|
||||||
from app.database.session import AsyncSessionLocal
|
from app.database.session import AsyncSessionLocal
|
||||||
|
|
||||||
|
|
@ -521,17 +573,13 @@ async def get_video_status(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> PollingVideoResponse:
|
) -> PollingVideoResponse:
|
||||||
"""creatomate_render_id로 영상 생성 작업의 상태를 조회합니다.
|
|
||||||
|
|
||||||
succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하고
|
|
||||||
Video 테이블의 status를 completed로, result_movie_url을 업데이트합니다.
|
|
||||||
"""
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[get_video_status] START - creatomate_render_id: {creatomate_render_id}"
|
f"[get_video_status] START - creatomate_render_id: {creatomate_render_id}"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
creatomate_service = CreatomateService()
|
creatomate_service = CreatomateService()
|
||||||
result = await creatomate_service.get_render_status_async(creatomate_render_id)
|
result = await creatomate_service.get_render_status(creatomate_render_id)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[get_video_status] Creatomate API response - creatomate_render_id: {creatomate_render_id}, status: {result.get('status')}"
|
f"[get_video_status] Creatomate API response - creatomate_render_id: {creatomate_render_id}, status: {result.get('status')}"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
27
config.py
27
config.py
|
|
@ -42,6 +42,7 @@ class ProjectSettings(BaseSettings):
|
||||||
|
|
||||||
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") # 기본값 추가
|
||||||
|
GEMINI_API_KEY: str = Field(default="your-gemeni-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"
|
||||||
|
|
@ -170,22 +171,19 @@ class CreatomateSettings(BaseSettings):
|
||||||
)
|
)
|
||||||
DEBUG_AUTO_LYRIC: bool = Field(
|
DEBUG_AUTO_LYRIC: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description="Creatomate 자동 가사 생성 기능 사용 여부",
|
description="Creatomate 자체 자동 가사 생성 기능 사용 여부",
|
||||||
|
)
|
||||||
|
LYRIC_SUBTITLE: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="영상 가사 표기 여부"
|
||||||
)
|
)
|
||||||
|
|
||||||
model_config = _base_config
|
model_config = _base_config
|
||||||
|
|
||||||
class PromptSettings(BaseSettings):
|
class PromptSettings(BaseSettings):
|
||||||
PROMPT_FOLDER_ROOT : str = Field(default="./app/utils/prompts/templates")
|
GOOGLE_SERVICE_ACCOUNT_JSON: str = Field(...)
|
||||||
|
PROMPT_SPREADSHEET: str = Field(...)
|
||||||
|
|
||||||
MARKETING_PROMPT_FILE_NAME : str = Field(default="marketing_prompt.txt")
|
|
||||||
MARKETING_PROMPT_MODEL : str = Field(default="gpt-5.2")
|
|
||||||
|
|
||||||
LYRIC_PROMPT_FILE_NAME : str = Field(default="lyric_prompt.txt")
|
|
||||||
LYRIC_PROMPT_MODEL : str = Field(default="gpt-5-mini")
|
|
||||||
|
|
||||||
YOUTUBE_PROMPT_FILE_NAME : str = Field(default="yt_upload_prompt.txt")
|
|
||||||
YOUTUBE_PROMPT_MODEL : str = Field(default="gpt-5-mini")
|
|
||||||
model_config = _base_config
|
model_config = _base_config
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -198,6 +196,14 @@ class RecoverySettings(BaseSettings):
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# ChatGPT API 설정
|
# ChatGPT API 설정
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
LLM_TIMEOUT: float = Field(
|
||||||
|
default=600.0,
|
||||||
|
description="LLM Default API 타임아웃 (초)",
|
||||||
|
)
|
||||||
|
LLM_MAX_RETRIES: int = Field(
|
||||||
|
default=1,
|
||||||
|
description="LLM API 응답 실패 시 최대 재시도 횟수",
|
||||||
|
)
|
||||||
CHATGPT_TIMEOUT: float = Field(
|
CHATGPT_TIMEOUT: float = Field(
|
||||||
default=600.0,
|
default=600.0,
|
||||||
description="ChatGPT API 타임아웃 (초). OpenAI Python SDK 기본값: 600초 (10분)",
|
description="ChatGPT API 타임아웃 (초). OpenAI Python SDK 기본값: 600초 (10분)",
|
||||||
|
|
@ -206,7 +212,6 @@ class RecoverySettings(BaseSettings):
|
||||||
default=1,
|
default=1,
|
||||||
description="ChatGPT API 응답 실패 시 최대 재시도 횟수",
|
description="ChatGPT API 응답 실패 시 최대 재시도 횟수",
|
||||||
)
|
)
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Suno API 설정
|
# Suno API 설정
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
# YouTube Analytics API: Core Metrics Dimensions
|
||||||
|
|
||||||
|
## 1. Core Metrics (측정항목)
|
||||||
|
|
||||||
|
API 요청 시 `metrics` 파라미터에 들어갈 수 있는 값들입니다. 이들은 모두 **숫자**로 반환됩니다.
|
||||||
|
|
||||||
|
### A. 시청 및 도달 (Views Reach)
|
||||||
|
|
||||||
|
가장 기본이 되는 성과 지표입니다.
|
||||||
|
|
||||||
|
|
||||||
|
| Metric ID | 설명 (한글/영문) | 단위 | 비고 |
|
||||||
|
| ----------------------------- | --------------- | --------------- | --------------- |
|
||||||
|
| `**views`** | **조회수** (Views) | 횟수 | 가장 기본 지표 |
|
||||||
|
| `**estimatedMinutesWatched`** | **예상 시청 시간** | **분 (Minutes)** | 초 단위가 아님에 주의 |
|
||||||
|
| `**averageViewDuration`** | **평균 시청 지속 시간** | **초 (Seconds)** | 영상 1회당 평균 시청 시간 |
|
||||||
|
| `**averageViewPercentage`** | **평균 시청 비율** | % (0~100) | 영상 길이 대비 시청 비율 |
|
||||||
|
|
||||||
|
|
||||||
|
### B. 참여 및 반응 (Engagement)
|
||||||
|
|
||||||
|
시청자의 능동적인 행동 지표입니다.
|
||||||
|
|
||||||
|
|
||||||
|
| Metric ID | 설명 (한글/영문) | 단위 | 비고 |
|
||||||
|
| ----------------------- | ------------------- | --- | ---------------- |
|
||||||
|
| `**likes`** | **좋아요** (Likes) | 횟수 | |
|
||||||
|
| `**dislikes`** | **싫어요** (Dislikes) | 횟수 | |
|
||||||
|
| `**comments`** | **댓글 수** (Comments) | 횟수 | |
|
||||||
|
| `**shares`** | **공유 수** (Shares) | 횟수 | 공유 버튼 클릭 횟수 |
|
||||||
|
| `**subscribersGained`** | **신규 구독자** | 명 | 해당 영상으로 유입된 구독자 |
|
||||||
|
| `**subscribersLost`** | **이탈 구독자** | 명 | 해당 영상 시청 후 구독 취소 |
|
||||||
|
|
||||||
|
|
||||||
|
### C. 수익 (Revenue) - *수익 창출 채널 전용(유튜브파트너프로그램(YPP) 사용자만 가능)*
|
||||||
|
|
||||||
|
|
||||||
|
| Metric ID | 설명 (한글/영문) | 단위 | 비고 |
|
||||||
|
| ------------------------ | ----------- | ---------------- | ------------------ |
|
||||||
|
| `**estimatedRevenue`** | **총 예상 수익** | 통화 (예: USD, KRW) | 광고 + 유튜브 프리미엄 등 포함 |
|
||||||
|
| `**estimatedAdRevenue`** | **광고 수익** | 통화 | 순수 광고 수익 |
|
||||||
|
| `monetizedPlaybacks` | 수익 창출 재생 수 | 횟수 | 광고가 1회 이상 노출된 재생 |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dimensions (차원)
|
||||||
|
|
||||||
|
API 요청 시 `dimensions` 파라미터에 들어갈 수 있는 값들입니다.
|
||||||
|
|
||||||
|
### A. 시간 및 영상 기준 (Time Item)
|
||||||
|
|
||||||
|
가장 많이 사용되는 필수 차원입니다.
|
||||||
|
|
||||||
|
|
||||||
|
| Dimension ID | 설명 | 사용 예시 (Use Case) | 필수 정렬(Sort) |
|
||||||
|
| ------------ | ------------------- | ---------------------- | ----------- |
|
||||||
|
| `**day`** | **일별** (Daily) | 최근 30일 조회수 추이 그래프 | `day` |
|
||||||
|
| `**month`** | **월별** (Monthly) | 월간 성장 리포트 | `month` |
|
||||||
|
| `**video`** | **영상별** (Per Video) | 인기 영상 랭킹 (Top Content) | `-views` |
|
||||||
|
| (없음) | **전체 합계** (Total) | 프로젝트 전체 성과 요약 (KPI) | (없음) |
|
||||||
|
|
||||||
|
|
||||||
|
### B. 시청자 분석 (Demographics)
|
||||||
|
|
||||||
|
**주의**: 이 차원들은 대부분 `video` 차원과 함께 사용할 수 없으며, 별도로 호출해야 합니다.
|
||||||
|
|
||||||
|
|
||||||
|
| Dimension ID | 설명 | 사용 예시 | 비고 |
|
||||||
|
| -------------- | ------- | --------------------------- | -------------- |
|
||||||
|
| `**ageGroup`** | **연령대** | 시청자 연령 분포 (18-24, 25-34...) | `video`와 혼용 불가 |
|
||||||
|
| `**gender`** | **성별** | 남녀 성비 (male, female) | `video`와 혼용 불가 |
|
||||||
|
| `**country`** | **국가** | 국가별 시청자 수 (KR, US...) | 지도 차트용, 채널 전체 기준 |
|
||||||
|
|
||||||
|
|
||||||
|
### C. 유입 및 기기 (Traffic Device)
|
||||||
|
|
||||||
|
|
||||||
|
| Dimension ID | 설명 | 반환 값 예시 |
|
||||||
|
| ------------------------------ | --------- | -------------------------------------- |
|
||||||
|
| `**insightTrafficSourceType`** | **유입 경로** | `YT_SEARCH` (검색), `RELATED_VIDEO` (추천) |
|
||||||
|
| `**deviceType`** | **기기 유형** | `MOBILE`, `DESKTOP`, `TV` |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 현재 사용 중인 API 호출 조합
|
||||||
|
|
||||||
|
대시보드에서 실제로 사용하는 7가지 호출 조합입니다. 모두 `ids=channel==MINE`으로 고정합니다.
|
||||||
|
|
||||||
|
### 1. KPI 요약 (`_fetch_kpi`) — 현재/이전 기간 각 1회
|
||||||
|
|
||||||
|
| 파라미터 | 값 |
|
||||||
|
| ---------- | ------------------------------------------------------------------------------------- |
|
||||||
|
| dimensions | (없음) |
|
||||||
|
| metrics | `views, likes, comments, shares, estimatedMinutesWatched, averageViewDuration, subscribersGained` |
|
||||||
|
| filters | `video==ID1,ID2,...` (업로드된 영상 ID 최대 30개) |
|
||||||
|
|
||||||
|
> 현재/이전 기간을 각각 호출하여 trend(증감률) 계산에 사용.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 월별 추이 차트 (`_fetch_monthly_data`) — 최근 12개월 / 이전 12개월 각 1회
|
||||||
|
|
||||||
|
| 파라미터 | 값 |
|
||||||
|
| ---------- | -------------------- |
|
||||||
|
| dimensions | `month` |
|
||||||
|
| metrics | `views` |
|
||||||
|
| filters | `video==ID1,ID2,...` |
|
||||||
|
| sort | `month` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 일별 추이 차트 (`_fetch_daily_data`) — 최근 30일 / 이전 30일 각 1회
|
||||||
|
|
||||||
|
| 파라미터 | 값 |
|
||||||
|
| ---------- | -------------------- |
|
||||||
|
| dimensions | `day` |
|
||||||
|
| metrics | `views` |
|
||||||
|
| filters | `video==ID1,ID2,...` |
|
||||||
|
| sort | `day` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 인기 영상 TOP 4 (`_fetch_top_videos`)
|
||||||
|
|
||||||
|
| 파라미터 | 값 |
|
||||||
|
| ---------- | ------------------------ |
|
||||||
|
| dimensions | `video` |
|
||||||
|
| metrics | `views, likes, comments` |
|
||||||
|
| filters | `video==ID1,ID2,...` |
|
||||||
|
| sort | `-views` |
|
||||||
|
| maxResults | `4` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 시청자 연령/성별 분포 (`_fetch_demographics`) — 채널 전체 기준
|
||||||
|
|
||||||
|
| 파라미터 | 값 |
|
||||||
|
| ---------- | ----------------------- |
|
||||||
|
| dimensions | `ageGroup, gender` |
|
||||||
|
| metrics | `viewerPercentage` |
|
||||||
|
|
||||||
|
> `ageGroup`, `gender` 차원은 `video` 필터와 혼용 불가 → 채널 전체 시청자 기준.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. 지역별 조회수 TOP 5 (`_fetch_region`) — 채널 전체 기준
|
||||||
|
|
||||||
|
| 파라미터 | 값 |
|
||||||
|
| ---------- | -------------------- |
|
||||||
|
| dimensions | `country` |
|
||||||
|
| metrics | `views` |
|
||||||
|
| sort | `-views` |
|
||||||
|
| maxResults | `5` |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. API 사용 시 주의사항 및 제약사항
|
||||||
|
|
||||||
|
### A. 영상 ID 개수 제한
|
||||||
|
|
||||||
|
- **권장**: 최근 생성된 영상 20~30개(최대 50개)를 DB에서 가져오고 해당 목록을 API로 호출
|
||||||
|
- **Analytics API 공식 한도**: 명시된 개수 제한은 없지만 URL 길이 제한 2000자 (이론상 최대 150개)
|
||||||
|
- **실질적 제한**: Analytics API는 계산이 복잡하여 ID 150개를 한 번에 던지면, 유튜브 서버 응답 시간(Latency)이 길어지고 **50개 이상일 때 문제가 발생**한다는 StackOverflow의 보고가 있음
|
||||||
|
- **Data API 비교**: 같은 유튜브의 Data API는 `videos.list` 50개 제한
|
||||||
|
|
||||||
|
### B. 데이터 지연 (Latency)
|
||||||
|
|
||||||
|
- Analytics API 데이터는 실시간이 아니며 **24~48시간 지연(Latency)** 발생
|
||||||
|
- 최신 데이터가 필요한 경우 이 점을 고려해야 함
|
||||||
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
-- ===================================================================
|
||||||
|
-- dashboard 테이블 생성 마이그레이션
|
||||||
|
-- 채널별 영상 업로드 기록을 저장하는 테이블
|
||||||
|
-- SocialUpload.social_account_id는 재연동 시 변경되므로,
|
||||||
|
-- 채널 ID(platform_user_id) 기준으로 안정적인 영상 필터링을 제공합니다.
|
||||||
|
-- 생성일: 2026-02-24
|
||||||
|
-- ===================================================================
|
||||||
|
|
||||||
|
-- dashboard 테이블 생성
|
||||||
|
CREATE TABLE IF NOT EXISTS dashboard (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '고유 식별자',
|
||||||
|
|
||||||
|
-- 관계 필드
|
||||||
|
user_uuid VARCHAR(36) NOT NULL COMMENT '사용자 UUID (user.user_uuid 참조)',
|
||||||
|
|
||||||
|
-- 플랫폼 정보
|
||||||
|
platform VARCHAR(20) NOT NULL COMMENT '플랫폼 (youtube, instagram)',
|
||||||
|
platform_user_id VARCHAR(100) NOT NULL COMMENT '채널 ID (재연동 후에도 불변)',
|
||||||
|
|
||||||
|
-- 플랫폼 결과
|
||||||
|
platform_video_id VARCHAR(100) NOT NULL COMMENT '플랫폼에서 부여한 영상 ID',
|
||||||
|
platform_url VARCHAR(500) NULL COMMENT '플랫폼에서의 영상 URL',
|
||||||
|
|
||||||
|
-- 메타데이터
|
||||||
|
title VARCHAR(200) NOT NULL COMMENT '영상 제목',
|
||||||
|
|
||||||
|
-- 시간 정보
|
||||||
|
uploaded_at DATETIME NOT NULL COMMENT 'SocialUpload 완료 시각 (정렬 기준)',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '레코드 생성 시각',
|
||||||
|
|
||||||
|
-- 외래키 제약조건
|
||||||
|
CONSTRAINT fk_dashboard_user FOREIGN KEY (user_uuid) REFERENCES user(user_uuid) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='채널별 영상 업로드 기록 테이블 (대시보드 통계 기준)';
|
||||||
|
|
||||||
|
-- 유니크 인덱스 (동일 채널에 동일 영상 중복 삽입 방지)
|
||||||
|
CREATE UNIQUE INDEX uq_vcu_video_channel ON dashboard(platform_video_id, platform_user_id);
|
||||||
|
|
||||||
|
-- 복합 인덱스 (사용자별 채널 필터링)
|
||||||
|
CREATE INDEX idx_vcu_user_platform ON dashboard(user_uuid, platform_user_id);
|
||||||
|
|
||||||
|
-- 인덱스 (날짜 범위 조회)
|
||||||
|
CREATE INDEX idx_vcu_uploaded_at ON dashboard(uploaded_at);
|
||||||
|
|
||||||
|
|
||||||
|
-- ===================================================================
|
||||||
|
-- 기존 데이터 마이그레이션
|
||||||
|
-- social_upload(status=completed) → dashboard INSERT IGNORE
|
||||||
|
-- 서버 기동 시 init_dashboard_table()에서 자동 실행됩니다.
|
||||||
|
-- 아래 쿼리는 수동 실행 시 참고용입니다.
|
||||||
|
-- ===================================================================
|
||||||
|
|
||||||
|
/*
|
||||||
|
INSERT IGNORE INTO dashboard (
|
||||||
|
user_uuid,
|
||||||
|
platform,
|
||||||
|
platform_user_id,
|
||||||
|
platform_video_id,
|
||||||
|
platform_url,
|
||||||
|
title,
|
||||||
|
uploaded_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
su.user_uuid,
|
||||||
|
su.platform,
|
||||||
|
sa.platform_user_id,
|
||||||
|
su.platform_video_id,
|
||||||
|
su.platform_url,
|
||||||
|
su.title,
|
||||||
|
su.uploaded_at
|
||||||
|
FROM social_upload su
|
||||||
|
JOIN social_account sa ON su.social_account_id = sa.id
|
||||||
|
WHERE
|
||||||
|
su.status = 'completed'
|
||||||
|
AND su.platform_video_id IS NOT NULL
|
||||||
|
AND su.uploaded_at IS NOT NULL
|
||||||
|
AND sa.platform_user_id IS NOT NULL;
|
||||||
|
*/
|
||||||
30
main.py
30
main.py
|
|
@ -12,6 +12,7 @@ from app.database.session import engine
|
||||||
from app.user.models import User, RefreshToken # noqa: F401
|
from app.user.models import User, RefreshToken # noqa: F401
|
||||||
|
|
||||||
from app.archive.api.routers.v1.archive import router as archive_router
|
from app.archive.api.routers.v1.archive import router as archive_router
|
||||||
|
from app.dashboard.api.routers.v1.dashboard import router as dashboard_router
|
||||||
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.user.api.routers.v1.auth import router as auth_router, test_router as auth_test_router
|
from app.user.api.routers.v1.auth import router as auth_router, test_router as auth_test_router
|
||||||
from app.user.api.routers.v1.social_account import router as social_account_router
|
from app.user.api.routers.v1.social_account import router as social_account_router
|
||||||
|
|
@ -221,6 +222,34 @@ tags_metadata = [
|
||||||
- `processing`: 플랫폼에서 처리 중
|
- `processing`: 플랫폼에서 처리 중
|
||||||
- `completed`: 업로드 완료
|
- `completed`: 업로드 완료
|
||||||
- `failed`: 업로드 실패
|
- `failed`: 업로드 실패
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dashboard",
|
||||||
|
"description": """YouTube Analytics 대시보드 API
|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
|
||||||
|
- `GET /dashboard/stats` - YouTube 영상 성과 통계 조회
|
||||||
|
|
||||||
|
## 제공 데이터
|
||||||
|
|
||||||
|
- **KPI 지표**: 총 조회수, 참여율, 시청 시간, 평균 시청 시간
|
||||||
|
- **월별 추이**: 최근 12개월 vs 이전 12개월 비교
|
||||||
|
- **인기 영상**: 조회수 TOP 10
|
||||||
|
- **시청자 분석**: 연령/성별/지역 분포
|
||||||
|
- **플랫폼 메트릭**: 구독자 증감
|
||||||
|
|
||||||
|
## 성능 최적화
|
||||||
|
|
||||||
|
- 6개 YouTube Analytics API 병렬 호출
|
||||||
|
- Redis 캐싱 (TTL: 1시간)
|
||||||
|
- 최근 30개 업로드 영상 기준 분석
|
||||||
|
|
||||||
|
## 사전 조건
|
||||||
|
|
||||||
|
- YouTube 계정이 연동되어 있어야 합니다
|
||||||
|
- 최소 1개 이상의 영상이 업로드되어 있어야 합니다
|
||||||
""",
|
""",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -365,6 +394,7 @@ app.include_router(social_upload_router, prefix="/social") # Social Upload 라
|
||||||
app.include_router(social_seo_router, prefix="/social") # Social Upload 라우터 추가
|
app.include_router(social_seo_router, prefix="/social") # Social Upload 라우터 추가
|
||||||
app.include_router(social_internal_router) # 내부 스케줄러 전용 라우터
|
app.include_router(social_internal_router) # 내부 스케줄러 전용 라우터
|
||||||
app.include_router(sns_router) # SNS API 라우터 추가
|
app.include_router(sns_router) # SNS API 라우터 추가
|
||||||
|
app.include_router(dashboard_router) # Dashboard API 라우터 추가
|
||||||
|
|
||||||
# DEBUG 모드에서만 테스트 라우터 등록
|
# DEBUG 모드에서만 테스트 라우터 등록
|
||||||
if prj_settings.DEBUG:
|
if prj_settings.DEBUG:
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ dependencies = [
|
||||||
"beautifulsoup4>=4.14.3",
|
"beautifulsoup4>=4.14.3",
|
||||||
"fastapi-cli>=0.0.16",
|
"fastapi-cli>=0.0.16",
|
||||||
"fastapi[standard]>=0.125.0",
|
"fastapi[standard]>=0.125.0",
|
||||||
|
"gspread>=6.2.1",
|
||||||
"openai>=2.13.0",
|
"openai>=2.13.0",
|
||||||
"playwright>=1.57.0",
|
"playwright>=1.57.0",
|
||||||
"pydantic-settings>=2.12.0",
|
"pydantic-settings>=2.12.0",
|
||||||
|
|
|
||||||
115
uv.lock
115
uv.lock
|
|
@ -178,6 +178,31 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.3.1"
|
version = "8.3.1"
|
||||||
|
|
@ -413,6 +438,32 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "google-auth"
|
||||||
|
version = "2.49.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cryptography" },
|
||||||
|
{ name = "pyasn1-modules" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "google-auth-oauthlib"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "google-auth" },
|
||||||
|
{ name = "requests-oauthlib" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a6/82/62482931dcbe5266a2680d0da17096f2aab983ecb320277d9556700ce00e/google_auth_oauthlib-1.3.1.tar.gz", hash = "sha256:14c22c7b3dd3d06dbe44264144409039465effdd1eef94f7ce3710e486cc4bfa", size = 21663, upload-time = "2026-03-30T22:49:56.408Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/e0/cb454a95f460903e39f101e950038ec24a072ca69d0a294a6df625cc1627/google_auth_oauthlib-1.3.1-py3-none-any.whl", hash = "sha256:1a139ef23f1318756805b0e95f655c238bffd29655329a2978218248da4ee7f8", size = 19247, upload-time = "2026-03-30T20:02:23.894Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "greenlet"
|
name = "greenlet"
|
||||||
version = "3.3.0"
|
version = "3.3.0"
|
||||||
|
|
@ -429,6 +480,19 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gspread"
|
||||||
|
version = "6.2.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "google-auth" },
|
||||||
|
{ name = "google-auth-oauthlib" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/91/83/42d1d813822ed016d77aabadc99b09de3b5bd68532fd6bae23fd62347c41/gspread-6.2.1.tar.gz", hash = "sha256:2c7c99f7c32ebea6ec0d36f2d5cbe8a2be5e8f2a48bde87ad1ea203eff32bd03", size = 82590, upload-time = "2025-05-14T15:56:25.254Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/76/563fb20dedd0e12794d9a12cfe0198458cc0501fdc7b034eee2166d035d5/gspread-6.2.1-py3-none-any.whl", hash = "sha256:6d4ec9f1c23ae3c704a9219026dac01f2b328ac70b96f1495055d453c4c184db", size = 59977, upload-time = "2025-05-14T15:56:24.014Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h11"
|
name = "h11"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
|
|
@ -654,6 +718,7 @@ dependencies = [
|
||||||
{ name = "beautifulsoup4" },
|
{ name = "beautifulsoup4" },
|
||||||
{ name = "fastapi", extra = ["standard"] },
|
{ name = "fastapi", extra = ["standard"] },
|
||||||
{ name = "fastapi-cli" },
|
{ name = "fastapi-cli" },
|
||||||
|
{ name = "gspread" },
|
||||||
{ name = "openai" },
|
{ name = "openai" },
|
||||||
{ name = "playwright" },
|
{ name = "playwright" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
|
|
@ -683,6 +748,7 @@ requires-dist = [
|
||||||
{ name = "beautifulsoup4", specifier = ">=4.14.3" },
|
{ 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 = "gspread", specifier = ">=6.2.1" },
|
||||||
{ name = "openai", specifier = ">=2.13.0" },
|
{ name = "openai", specifier = ">=2.13.0" },
|
||||||
{ name = "playwright", specifier = ">=1.57.0" },
|
{ name = "playwright", specifier = ">=1.57.0" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.12.0" },
|
{ name = "pydantic-settings", specifier = ">=2.12.0" },
|
||||||
|
|
@ -703,6 +769,15 @@ dev = [
|
||||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oauthlib"
|
||||||
|
version = "3.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openai"
|
name = "openai"
|
||||||
version = "2.15.0"
|
version = "2.15.0"
|
||||||
|
|
@ -807,6 +882,18 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
|
{ url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyasn1-modules"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyasn1" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pycparser"
|
name = "pycparser"
|
||||||
version = "3.0"
|
version = "3.0"
|
||||||
|
|
@ -1010,6 +1097,34 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" },
|
{ url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.33.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests-oauthlib"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "oauthlib" },
|
||||||
|
{ name = "requests" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rich"
|
name = "rich"
|
||||||
version = "14.2.0"
|
version = "14.2.0"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue