Compare commits

..

2 Commits

Author SHA1 Message Date
hbyang 6e70c416b8 스케줄 작업 완료 . 2026-03-05 15:24:06 +09:00
hbyang 7a887153ab 내부 youtube 업로드 endpoint 적용 . 2026-03-03 16:16:23 +09:00
64 changed files with 2367 additions and 5812 deletions

5
.gitignore vendored
View File

@ -49,7 +49,4 @@ logs/
*.yml *.yml
Dockerfile Dockerfile
.dockerignore .dockerignore
zzz/
credentials/service_account.json

View File

@ -24,11 +24,6 @@ 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")

View File

@ -309,23 +309,6 @@ 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):
# 에러 메시지 로깅 (한글 포함 가능) # 에러 메시지 로깅 (한글 포함 가능)

View File

@ -1,5 +0,0 @@
"""
Dashboard Module
YouTube Analytics API를 활용한 대시보드 기능을 제공합니다.
"""

View File

@ -1,3 +0,0 @@
"""
Dashboard API Module
"""

View File

@ -1,3 +0,0 @@
"""
Dashboard Routers
"""

View File

@ -1,7 +0,0 @@
"""
Dashboard V1 Routers
"""
from app.dashboard.api.routers.v1.dashboard import router
__all__ = ["router"]

View File

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

View File

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

View File

@ -1,112 +0,0 @@
"""
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 테이블 이미 존재 - 스킵")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,542 +0,0 @@
"""
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, "기타")

View File

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

View File

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

View File

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

View File

@ -6,7 +6,6 @@ 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")
@ -74,13 +73,12 @@ 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, ImageTag # noqa: F401 from app.home.models import Image, Project, MarketingIntel # 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 = [
@ -96,8 +94,6 @@ 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...")
@ -172,7 +168,6 @@ 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

View File

@ -9,10 +9,9 @@ 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, ImageTag from app.home.models import Image, MarketingIntel
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 (
@ -30,13 +29,12 @@ 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.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError from app.utils.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, URLNotFoundException from app.utils.nvMapScraper import NvMapScraper, GraphQLException
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
# 로거 설정 # 로거 설정
@ -220,15 +218,6 @@ 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(
@ -462,6 +451,255 @@ 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)",
@ -750,10 +988,6 @@ 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}, "
@ -769,36 +1003,3 @@ 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()

View File

@ -9,8 +9,7 @@ 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, Computed, Index, Integer, String, Text, JSON, func from sqlalchemy import Boolean, DateTime, ForeignKey, 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
@ -301,12 +300,6 @@ 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,
@ -315,50 +308,13 @@ class MarketingIntel(Base):
) )
def __repr__(self) -> str: def __repr__(self) -> str:
return ( task_id_str = (
f"<MarketingIntel(id={self.id}, place_id='{self.place_id}')>" (self.task_id[:10] + "...") if len(self.task_id) > 10 else self.task_id
)
img_name_str = (
(self.img_name[:10] + "...") if len(self.img_name) > 10 else self.img_name
) )
return (
class ImageTag(Base): f"<Image(id={self.id}, task_id='{task_id_str}', img_name='{img_name_str}')>"
""" )
이미지 태그 테이블
"""
__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",
)

View File

@ -41,8 +41,8 @@ from app.lyric.schemas.lyric import (
LyricListItem, LyricListItem,
LyricStatusResponse, LyricStatusResponse,
) )
from app.lyric.worker.lyric_task import generate_lyric_background, generate_subtitle_background from app.lyric.worker.lyric_task import generate_lyric_background
from app.utils.prompts.chatgpt_prompt import ChatgptService from app.utils.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,6 +253,17 @@ 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",
@ -340,7 +351,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,
@ -348,12 +359,6 @@ async def generate_lyric(
lyric_input_data=lyric_input_data, lyric_input_data=lyric_input_data,
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)")

View File

@ -23,7 +23,7 @@ Lyric API Schemas
""" """
from datetime import datetime from datetime import datetime
from typing import Optional, Literal from typing import Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@ -42,8 +42,7 @@ class GenerateLyricRequest(BaseModel):
"region": "군산", "region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을", "detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean", "language": "Korean",
"m_id" : 2, "m_id" : 1
"orientation" : "vertical"
} }
""" """
@ -55,8 +54,7 @@ class GenerateLyricRequest(BaseModel):
"region": "군산", "region": "군산",
"detail_region_info": "군산 신흥동 말랭이 마을", "detail_region_info": "군산 신흥동 말랭이 마을",
"language": "Korean", "language": "Korean",
"m_id" : 1, "m_id" : 1
"orientation" : "vertical"
} }
} }
) )
@ -70,11 +68,7 @@ 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 값")

View File

@ -7,15 +7,11 @@ 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.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError from app.utils.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
@ -104,6 +100,13 @@ 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
@ -155,55 +158,3 @@ 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

View File

@ -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(

View File

@ -1,37 +1,131 @@
"""
소셜 SEO API 라우터
SEO 관련 엔드포인트를 제공합니다. import logging, json
비즈니스 로직은 SeoService에 위임합니다.
"""
import logging from redis.asyncio import Redis
from fastapi import APIRouter, Depends from config import social_oauth_settings, db_settings
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 description 생성", summary="유튜브 SEO descrption 생성",
description="유튜브 업로드 시 사용할 description을 SEO 적용하여 생성", description="유튜브 업로드 시 사용할 descrption을 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(
request_body.task_id, current_user, session # TODO : 나중에 Session Task_id 검증 미들웨어 만들면 추가해주세요.
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

View File

@ -2,34 +2,39 @@
소셜 업로드 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 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 ( from app.social.schemas import (
MessageResponse, MessageResponse,
SocialUploadHistoryItem,
SocialUploadHistoryResponse, SocialUploadHistoryResponse,
SocialUploadRequest, SocialUploadRequest,
SocialUploadResponse, SocialUploadResponse,
SocialUploadStatusResponse, SocialUploadStatusResponse,
) )
from app.social.services import SocialUploadService, social_account_service from app.social.services import 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(
"", "",
@ -66,7 +71,126 @@ 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(
@ -80,7 +204,43 @@ 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(
@ -107,8 +267,98 @@ 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,
) )
@ -124,7 +374,53 @@ 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(
@ -138,4 +434,38 @@ 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="업로드가 취소되었습니다.",
)

View File

@ -91,7 +91,6 @@ 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", # 사용자 프로필
] ]

View File

@ -1,5 +1,7 @@
""" """
소셜 업로드 관련 Pydantic 스키마 Social Media Schemas
소셜 미디어 연동 관련 Pydantic 스키마를 정의합니다.
""" """
from datetime import datetime from datetime import datetime
@ -7,7 +9,123 @@ from typing import Any, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from app.social.constants import PrivacyStatus, UploadStatus from app.social.constants import PrivacyStatus, SocialPlatform, 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):
@ -41,7 +159,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", "category_id": "22", # YouTube 카테고리
}, },
} }
} }
@ -160,3 +278,50 @@ 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": "작업이 완료되었습니다.",
}
}
)

View File

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

View File

@ -1,125 +0,0 @@
"""
소셜 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": "작업이 완료되었습니다.",
}
}
)

View File

@ -1,37 +0,0 @@
"""
소셜 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": ["여기에", "더미", "해시태그"]
}
}
)

View File

@ -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로 변환

View File

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

View File

@ -1,12 +0,0 @@
"""
소셜 서비스 베이스 클래스
"""
from sqlalchemy.ext.asyncio import AsyncSession
class BaseService:
"""서비스 레이어 베이스 클래스"""
def __init__(self, session: AsyncSession | None = None):
self.session = session

View File

@ -1,129 +0,0 @@
"""
유튜브 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()

View File

@ -1,391 +0,0 @@
"""
소셜 업로드 서비스
업로드 요청, 상태 조회, 이력 조회, 재시도, 취소 관련 비즈니스 로직을 처리합니다.
"""
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="업로드가 취소되었습니다.",
)

View File

@ -18,7 +18,6 @@ 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
@ -319,7 +318,6 @@ 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)

View File

@ -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.lyric.schemas.lyrics_schema import ( from app.lyrics.schemas.lyrics_schema import (
AttributeData, AttributeData,
PromptTemplateData, PromptTemplateData,
SongFormData, SongFormData,
SongSampleData, SongSampleData,
StoreData, StoreData,
) )
from app.utils.prompts.chatgpt_prompt import chatgpt_api from app.utils.chatgpt_prompt import chatgpt_api
logger = get_logger("song") logger = get_logger("song")

View File

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

View File

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

View File

@ -31,13 +31,11 @@ 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
# 로거 설정 # 로거 설정
@ -222,28 +220,6 @@ 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 클라이언트를 반환합니다. 없으면 생성합니다."""
@ -288,10 +264,23 @@ 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:
@ -305,7 +294,14 @@ class CreatomateService:
self.orientation = orientation self.orientation = orientation
# orientation에 따른 템플릿 설정 가져오기 # orientation에 따른 템플릿 설정 가져오기
self.template_id = select_template(orientation) config = self.TEMPLATE_CONFIG.get(
orientation, self.TEMPLATE_CONFIG["vertical"]
)
self.template_id = config["template_id"]
self.target_duration = (
target_duration if target_duration is not None else config["duration"]
)
self.headers = { self.headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
@ -402,6 +398,14 @@ 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:
"""템플릿 정보를 파싱하여 리소스 이름을 추출합니다.""" """템플릿 정보를 파싱하여 리소스 이름을 추출합니다."""
@ -428,112 +432,52 @@ class CreatomateService:
result.update(result_element_dict) result.update(result_element_dict)
return result return result
async def parse_template_name_tag(resource_name : str) -> list:
tag_list = []
tag_list = resource_name.split("_")
return tag_list
def counting_component(
self,
template : dict,
target_template_type : 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()): async def template_connect_resource_blackbox(
if template_type == target_template_type:
count += 1
return count
def template_matching_taged_image(
self, self,
template : dict, template_id: str,
taged_image_list : list, # [{"image_name" : str , "image_tag" : dict}] image_url_list: list[str],
lyric: str,
music_url: str, music_url: str,
address : str, address: str = None
duplicate : bool = False ) -> dict:
) -> 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 slot_idx, (template_component_name, template_type) in enumerate(template_component_data.items()): for idx, (template_component_name, template_type) in enumerate(
template_component_data.items()
):
match template_type: match template_type:
case "image": case "image":
image_score_list = self.calculate_image_slot_score_multi(taged_image_list, template_component_name) modifications[template_component_name] = image_url_list[
maximum_idx = image_score_list.index(max(image_score_list)) idx % len(image_url_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:
@ -729,6 +673,14 @@ 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:
"""렌더링 작업의 상태를 조회합니다. """렌더링 작업의 상태를 조회합니다.
@ -752,58 +704,47 @@ 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["track"] not in track_maximum_duration: if elem["type"] == "audio":
continue continue
if "time" not in elem or elem["time"] == 0: # elem is auto / 만약 마지막 elem이 auto인데 그 앞에 time이 있는 elem 일 시 버그 발생 확률 있음 total_template_duration += elem["duration"]
track_maximum_duration[elem["track"]] += elem["duration"] if "animations" not in elem:
continue
if "animations" not in elem: for animation in elem["animations"]:
continue assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
for animation in elem["animations"]: if animation["transition"]:
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요 total_template_duration -= animation["duration"]
if "transition" in animation and animation["transition"]:
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
elem["duration"] = elem["duration"] * extend_rate
if "time" in elem:
elem["time"] = elem["time"] * extend_rate
if "duration" in elem:
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"]:
@ -844,25 +785,4 @@ class CreatomateService:
return autotext_template_v_1 return autotext_template_v_1
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

View File

@ -16,10 +16,6 @@ class GraphQLException(Exception):
"""GraphQL 요청 실패 시 발생하는 예외""" """GraphQL 요청 실패 시 발생하는 예외"""
pass pass
class URLNotFoundException(Exception):
"""Place ID 발견 불가능 시 발생하는 예외"""
pass
class CrawlingTimeoutException(Exception): class CrawlingTimeoutException(Exception):
"""크롤링 타임아웃 시 발생하는 예외""" """크롤링 타임아웃 시 발생하는 예외"""
@ -90,28 +86,34 @@ 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 URLNotFoundException("This URL does not contain a place ID") raise GraphQLException("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 URLNotFoundException("Failed to parse place ID from URL") raise GraphQLException("Failed to parse place ID from URL")
return match[1] return match[1]
async def scrap(self): async def scrap(self):
place_id = await self.parse_url() try:
data = await self._call_get_accommodation(place_id) place_id = await self.parse_url()
self.rawdata = data data = await self._call_get_accommodation(place_id)
fac_data = await self._get_facility_string(place_id) self.rawdata = data
# Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것. fac_data = await self._get_facility_string(place_id)
self.place_id = self.data_source_identifier + place_id # Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것.
self.rawdata["facilities"] = fac_data self.place_id = self.data_source_identifier + place_id
self.image_link_list = [ self.rawdata["facilities"] = fac_data
nv_image["origin"] self.image_link_list = [
for nv_image in data["data"]["business"]["images"]["images"] nv_image["origin"]
] for nv_image in data["data"]["business"]["images"]["images"]
self.base_info = data["data"]["business"]["base"] ]
self.facility_info = fac_data self.base_info = data["data"]["business"]["base"]
self.scrap_type = "GraphQL" self.facility_info = fac_data
self.scrap_type = "GraphQL"
except GraphQLException:
logger.debug("GraphQL failed, fallback to Playwright")
self.scrap_type = "Playwright"
pass # 나중에 pw 이용한 crawling으로 fallback 추가
return return

View File

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

View File

@ -1,89 +1,65 @@
import gspread import os, json
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():
sheet_name: str prompt_template_path : str #프롬프트 경로
prompt_template: str prompt_template : str # fstring 포맷
prompt_model: str prompt_model : str
prompt_input_class = BaseModel prompt_input_class = BaseModel # pydantic class 자체를(instance 아님) 변수로 가짐
prompt_output_class = BaseModel prompt_output_class = BaseModel
def __init__(self, sheet_name, prompt_input_class, prompt_output_class): def __init__(self, prompt_template_path, prompt_input_class, prompt_output_class, prompt_model):
self.sheet_name = sheet_name self.prompt_template_path = prompt_template_path
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.prompt_model = self._read_from_sheets() self.prompt_template = self.read_prompt()
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.prompt_model = self._read_from_sheets() self.prompt_template = self.read_prompt()
def build_prompt(self, input_data:dict, silent:bool = False) -> str: def read_prompt(self) -> tuple[str, dict]:
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(
sheet_name="marketing", prompt_template_path = os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.MARKETING_PROMPT_FILE_NAME),
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(
sheet_name="lyric", prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.LYRIC_PROMPT_FILE_NAME),
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(
sheet_name="yt_upload", prompt_template_path=os.path.join(prompt_settings.PROMPT_FOLDER_ROOT, prompt_settings.YOUTUBE_PROMPT_FILE_NAME),
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()

View File

@ -1,5 +1,3 @@
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

View File

@ -1,110 +0,0 @@
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="이미지의 내러티브 상 점수")

View File

@ -7,11 +7,13 @@ 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="마케팅 포지션 핵심 가치")
@ -20,12 +22,14 @@ 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="셀링포인트 카테고리(한글)")

View File

@ -1,31 +0,0 @@
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="피칭 리스트")
),
)

View File

@ -0,0 +1,64 @@
[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"]

View File

@ -0,0 +1,77 @@
[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

View File

@ -0,0 +1,42 @@
# 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.

View File

@ -1,233 +0,0 @@
# 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}

View File

@ -0,0 +1,143 @@
[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 brands 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 23 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 12 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: 4565 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 (34 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 35 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 (12)
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.

View File

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

View File

@ -14,8 +14,6 @@ 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
@ -25,11 +23,10 @@ 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, MarketingIntel, ImageTag from app.home.models import Image, Project
from app.lyric.models import Lyric from app.lyric.models import Lyric
from app.song.models import Song, SongTimestamp from app.song.models import Song, 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 (
@ -39,7 +36,6 @@ 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
@ -148,34 +144,6 @@ 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 =====
@ -230,12 +198,6 @@ 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()
@ -325,66 +287,50 @@ 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}, (song duration: {song_duration})" f"[generate_video] Using template_id: {creatomate_service.template_id}, duration: {creatomate_service.target_duration} (song duration: {song_duration})"
) )
# 6-1. 템플릿 조회 (비동기) # 6-1. 템플릿 조회 (비동기)
template = await creatomate_service.get_one_template_data( template = await creatomate_service.get_one_template_data_async(
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,
# music_url=music_url, lyric=lyrics,
# address=store_address music_url=music_url,
taged_image_list = await get_image_tags_by_task_id(task_id) address=store_address
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,
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,
song_duration, creatomate_service.target_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(
@ -393,10 +339,13 @@ async def generate_video(
) )
song_timestamp_list = song_timestamp_result.scalars().all() song_timestamp_list = song_timestamp_result.scalars().all()
logger.debug(f"[generate_video] song_timestamp_list count: {len(song_timestamp_list)}") logger.debug(
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(f"[generate_video] timestamp[{i}]: lyric_line={ts.lyric_line}, start_time={ts.start_time}, end_time={ts.end_time}") logger.debug(
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" :
@ -406,32 +355,33 @@ 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)) else :
else : text_template = creatomate_service.get_text_template()
text_template = creatomate_service.get_text_template() for idx, aligned in enumerate(song_timestamp_list):
for idx, aligned in enumerate(song_timestamp_list): caption = creatomate_service.lining_lyric(
caption = creatomate_service.lining_lyric( text_template,
text_template, idx,
idx, aligned.lyric_line,
aligned.lyric_line, aligned.start_time,
aligned.start_time, aligned.end_time,
aligned.end_time, lyric_font
lyric_font )
) final_template["source"]["elements"].append(caption)
final_template["source"]["elements"].append(caption)
# END - LYRIC AUTO 결정부 # END - LYRIC AUTO 결정부
# 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( render_response = await creatomate_service.make_creatomate_custom_call_async(
final_template["source"], final_template["source"],
) )
logger.debug(
logger.debug(f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}") 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:
@ -452,8 +402,6 @@ 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
@ -573,13 +521,17 @@ 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(creatomate_render_id) result = await creatomate_service.get_render_status_async(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

View File

@ -42,7 +42,6 @@ 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"
@ -171,19 +170,22 @@ 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):
GOOGLE_SERVICE_ACCOUNT_JSON: str = Field(...) PROMPT_FOLDER_ROOT : str = Field(default="./app/utils/prompts/templates")
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
@ -196,14 +198,6 @@ 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분)",
@ -212,6 +206,7 @@ class RecoverySettings(BaseSettings):
default=1, default=1,
description="ChatGPT API 응답 실패 시 최대 재시도 횟수", description="ChatGPT API 응답 실패 시 최대 재시도 횟수",
) )
# ============================================================ # ============================================================
# Suno API 설정 # Suno API 설정
# ============================================================ # ============================================================

View File

@ -1,173 +0,0 @@
# 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)** 발생
- 최신 데이터가 필요한 경우 이 점을 고려해야 함

View File

@ -1,77 +0,0 @@
-- ===================================================================
-- 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
View File

@ -12,7 +12,6 @@ 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
@ -222,34 +221,6 @@ 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 이상의 영상이 업로드되어 있어야 합니다
""", """,
}, },
{ {
@ -394,7 +365,6 @@ 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:

View File

@ -12,7 +12,6 @@ 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
View File

@ -178,31 +178,6 @@ 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"
@ -438,32 +413,6 @@ 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"
@ -480,19 +429,6 @@ 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"
@ -718,7 +654,6 @@ 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" },
@ -748,7 +683,6 @@ 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" },
@ -769,15 +703,6 @@ 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"
@ -882,18 +807,6 @@ 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"
@ -1097,34 +1010,6 @@ 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"