From b0cebb97ef0f51e7a5b977c821397a29aadf29b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 24 Feb 2026 15:23:11 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- app/core/common.py | 5 + app/core/exceptions.py | 14 + app/dashboard/__init__.py | 5 + app/dashboard/api/__init__.py | 3 + app/dashboard/api/routers/__init__.py | 3 + app/dashboard/api/routers/v1/__init__.py | 7 + app/dashboard/api/routers/v1/dashboard.py | 480 +++++++++++++++++ app/dashboard/exceptions.py | 195 +++++++ app/dashboard/migration.py | 112 ++++ app/dashboard/models.py | 134 +++++ app/dashboard/redis_cache.py | 173 ++++++ app/dashboard/schemas/__init__.py | 33 ++ app/dashboard/schemas/dashboard_schema.py | 352 ++++++++++++ app/dashboard/services/__init__.py | 13 + app/dashboard/services/data_processor.py | 565 ++++++++++++++++++++ app/dashboard/services/youtube_analytics.py | 497 +++++++++++++++++ app/dashboard/tasks.py | 71 +++ app/database/session.py | 2 + app/social/constants.py | 1 + app/social/worker/upload_task.py | 2 + main.py | 30 ++ 22 files changed, 2700 insertions(+), 1 deletion(-) create mode 100644 app/dashboard/__init__.py create mode 100644 app/dashboard/api/__init__.py create mode 100644 app/dashboard/api/routers/__init__.py create mode 100644 app/dashboard/api/routers/v1/__init__.py create mode 100644 app/dashboard/api/routers/v1/dashboard.py create mode 100644 app/dashboard/exceptions.py create mode 100644 app/dashboard/migration.py create mode 100644 app/dashboard/models.py create mode 100644 app/dashboard/redis_cache.py create mode 100644 app/dashboard/schemas/__init__.py create mode 100644 app/dashboard/schemas/dashboard_schema.py create mode 100644 app/dashboard/services/__init__.py create mode 100644 app/dashboard/services/data_processor.py create mode 100644 app/dashboard/services/youtube_analytics.py create mode 100644 app/dashboard/tasks.py diff --git a/.gitignore b/.gitignore index 53dc64e..4b822ab 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,6 @@ logs/ *.yml Dockerfile -.dockerignore \ No newline at end of file +.dockerignore + +zzz/ \ No newline at end of file diff --git a/app/core/common.py b/app/core/common.py index 85cb08d..fc41dbb 100644 --- a/app/core/common.py +++ b/app/core/common.py @@ -24,6 +24,11 @@ async def lifespan(app: FastAPI): await create_db_tables() logger.info("Database tables created (DEBUG mode)") + + # dashboard 테이블 초기화 및 기존 데이터 마이그레이션 (모든 환경) + from app.dashboard.migration import init_dashboard_table + await init_dashboard_table() + await NvMapPwScraper.initiate_scraper() except asyncio.TimeoutError: logger.error("Database initialization timed out") diff --git a/app/core/exceptions.py b/app/core/exceptions.py index 4a4e277..03fa3dd 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -309,6 +309,20 @@ def add_exception_handlers(app: FastAPI): content=content, ) + # DashboardException 핸들러 추가 + from app.dashboard.exceptions import DashboardException + + @app.exception_handler(DashboardException) + def dashboard_exception_handler(request: Request, exc: DashboardException) -> Response: + logger.debug(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) def internal_server_error_handler(request, exception): # 에러 메시지 로깅 (한글 포함 가능) diff --git a/app/dashboard/__init__.py b/app/dashboard/__init__.py new file mode 100644 index 0000000..0376ae3 --- /dev/null +++ b/app/dashboard/__init__.py @@ -0,0 +1,5 @@ +""" +Dashboard Module + +YouTube Analytics API를 활용한 대시보드 기능을 제공합니다. +""" diff --git a/app/dashboard/api/__init__.py b/app/dashboard/api/__init__.py new file mode 100644 index 0000000..cd51ec2 --- /dev/null +++ b/app/dashboard/api/__init__.py @@ -0,0 +1,3 @@ +""" +Dashboard API Module +""" diff --git a/app/dashboard/api/routers/__init__.py b/app/dashboard/api/routers/__init__.py new file mode 100644 index 0000000..396e81c --- /dev/null +++ b/app/dashboard/api/routers/__init__.py @@ -0,0 +1,3 @@ +""" +Dashboard Routers +""" diff --git a/app/dashboard/api/routers/v1/__init__.py b/app/dashboard/api/routers/v1/__init__.py new file mode 100644 index 0000000..2b4095f --- /dev/null +++ b/app/dashboard/api/routers/v1/__init__.py @@ -0,0 +1,7 @@ +""" +Dashboard V1 Routers +""" + +from app.dashboard.api.routers.v1.dashboard import router + +__all__ = ["router"] diff --git a/app/dashboard/api/routers/v1/dashboard.py b/app/dashboard/api/routers/v1/dashboard.py new file mode 100644 index 0000000..3033e85 --- /dev/null +++ b/app/dashboard/api/routers/v1/dashboard.py @@ -0,0 +1,480 @@ +""" +Dashboard API 라우터 + +YouTube Analytics 기반 대시보드 통계를 제공합니다. +""" + +import json +import logging +from datetime import date, datetime, timedelta +from typing import Literal + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dashboard.exceptions import ( + NoVideosFoundError, + YouTubeAccountNotConnectedError, + YouTubeAccountNotFoundError, + YouTubeAccountSelectionRequiredError, + YouTubeTokenExpiredError, +) +from app.dashboard.schemas import ( + CacheDeleteResponse, + ConnectedAccount, + ConnectedAccountsResponse, + DashboardResponse, + TopContent, +) +from app.dashboard.services import DataProcessor, YouTubeAnalyticsService +from app.dashboard.redis_cache import ( + delete_cache, + delete_cache_pattern, + get_cache, + set_cache, +) +from app.database.session import get_session +from app.dashboard.models import Dashboard +from app.social.exceptions import TokenExpiredError +from app.social.services import SocialAccountService +from app.user.dependencies.auth import get_current_user +from app.user.models import SocialAccount, User + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/dashboard", tags=["Dashboard"]) + + +@router.get( + "/accounts", + response_model=ConnectedAccountsResponse, + summary="연결된 소셜 계정 목록 조회", + description=""" +연결된 소셜 계정 목록을 반환합니다. + +여러 계정이 연결된 경우, 반환된 `id` 값을 `/dashboard/stats?social_account_id=`에 전달하여 계정을 선택합니다. +""", +) +async def get_connected_accounts( + current_user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +) -> ConnectedAccountsResponse: + 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() + + # platform_user_id 기준 + seen_platform_ids: set[str] = set() + connected = [] + for acc in sorted( + accounts_raw, key=lambda a: a.connected_at or datetime.min, reverse=True + ): + if acc.platform_user_id in seen_platform_ids: + continue + seen_platform_ids.add(acc.platform_user_id) + 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 ConnectedAccountsResponse(accounts=connected) + + +@router.get( + "/stats", + response_model=DashboardResponse, + summary="대시보드 통계 조회", + description=""" +YouTube Analytics API를 활용한 대시보드 통계를 조회합니다. + +## 주요 기능 +- 최근 30개 업로드 영상 기준 통계 제공 +- KPI 지표: 업로드 영상 수, 총 조회수, 시청 시간, 평균 시청 시간, 신규 구독자, 좋아요, 댓글, 공유 +- 월별 추이: 최근 12개월 vs 이전 12개월 비교 +- 인기 영상 TOP 4 +- 시청자 분석: 연령/성별/지역 분포 + +## 성능 최적화 +- 8개 YouTube Analytics API를 병렬로 호출 +- Redis 캐싱 적용 (TTL: 12시간) + +## 사전 조건 +- YouTube 계정이 연동되어 있어야 합니다 +- 최소 1개 이상의 영상이 업로드되어 있어야 합니다 + +## 조회 모드 +- `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: + """ + 대시보드 통계 조회 + + Args: + mode: 조회 모드 (day: 최근 30일, month: 최근 12개월) + platform_user_id: 사용할 YouTube 채널 ID (여러 계정 연결 시 필수, 재연동해도 불변) + current_user: 현재 인증된 사용자 + session: 데이터베이스 세션 + + Returns: + DashboardResponse: 대시보드 통계 데이터 + + Raises: + YouTubeAccountNotConnectedError: YouTube 계정이 연동되어 있지 않음 + YouTubeAccountSelectionRequiredError: 여러 계정이 연결되어 있으나 계정 미선택 + YouTubeAccountNotFoundError: 지정한 계정을 찾을 수 없음 + NoVideosFoundError: 업로드된 영상이 없음 + YouTubeTokenExpiredError: YouTube 토큰 만료 (재연동 필요) + YouTubeAPIError: YouTube Analytics API 호출 실패 + """ + logger.info( + f"[DASHBOARD] 통계 조회 시작 - " + f"user_uuid={current_user.user_uuid}, mode={mode}, platform_user_id={platform_user_id}" + ) + + # 1. 모드별 날짜 자동 계산 + today = date.today() + + if mode == "day": + # 48시간 지연 적용: 오늘 기준 -2일을 end로 사용 + # ex) 오늘 2/20 → end=2/18, start=1/20 + end_dt = today - timedelta(days=2) + kpi_end_dt = end_dt + start_dt = end_dt - timedelta(days=29) + period_desc = "최근 30일" + else: # mode == "month" + # 월별 차트: dimensions=month API는 YYYY-MM-01 형식 필요 + # ex) 오늘 2/24 → end=2026-02-01, start=2025-03-01 → 2025-03 ~ 2026-02 (12개월) + end_dt = today.replace(day=1) + # KPI 등 집계형 API: 48시간 지연 적용하여 현재 월 전체 데이터 포함 + 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) + period_desc = "최근 12개월" + + 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 계정 연동 확인 + 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() + + # platform_user_id 기준으로 중복 제거 (가장 최근 연동 계정 우선) + seen_platform_ids_stats: set[str] = set() + social_accounts = [] + for acc in sorted( + social_accounts_raw, key=lambda a: a.connected_at or datetime.min, reverse=True + ): + if acc.platform_user_id not in seen_platform_ids_stats: + seen_platform_ids_stats.add(acc.platform_user_id) + social_accounts.append(acc) + + if not social_accounts: + logger.warning( + f"[NO YOUTUBE ACCOUNT] YouTube 계정 미연동 - " + f"user_uuid={current_user.user_uuid}" + ) + 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: + logger.warning( + f"[ACCOUNT NOT FOUND] 지정 계정 없음 - " + f"user_uuid={current_user.user_uuid}, platform_user_id={platform_user_id}" + ) + raise YouTubeAccountNotFoundError() + social_account = matched[0] + elif len(social_accounts) == 1: + social_account = social_accounts[0] + else: + logger.warning( + f"[MULTI ACCOUNT] 계정 선택 필요 - " + f"user_uuid={current_user.user_uuid}, count={len(social_accounts)}" + ) + raise YouTubeAccountSelectionRequiredError() + + logger.debug( + f"[2] YouTube 계정 확인 완료 - platform_user_id={social_account.platform_user_id}" + ) + + # 3. 기간 내 업로드 영상 수 조회 + 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 <= kpi_end_dt, + ) + ) + period_video_count = count_result.scalar() or 0 + logger.debug(f"[3] 기간 내 업로드 영상 수 - count={period_video_count}") + + # 4. Redis 캐시 조회 + # platform_user_id 기준 캐시 키: 재연동해도 채널 ID는 불변 → 캐시 유지됨 + 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"]) + for metric in response.content_metrics: + if metric.id == "uploaded-videos": + metric.value = str(period_video_count) + break + return response + except (json.JSONDecodeError, KeyError): + logger.warning(f"[CACHE PARSE ERROR] 포맷 오류, 무시 - key={cache_key}") + + logger.debug("[4] 캐시 MISS - YouTube API 호출 필요") + + # 5. 최근 30개 업로드 영상 조회 (Analytics API 전달용) + # YouTube Analytics API 제약사항: + # - 영상 개수: 20~30개 권장 (최대 50개, 그 이상은 응답 지연 발생) + # - URL 길이: 2000자 제한 (video ID 11자 × 30개 = 330자로 안전) + 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() + # 테스트를 위해 주석처리 + # if not rows: + # logger.warning( + # f"[NO VIDEOS] 업로드된 영상 없음 - " f"user_uuid={current_user.user_uuid}" + # ) + # raise NoVideosFoundError() + + # logger.debug(f"[5] 영상 조회 완료 - count={len(rows)}") + + # 6. video_ids + 메타데이터 조회용 dict 구성 + video_ids = [] + video_lookup: dict[str, tuple[str, datetime]] = {} # {video_id: (title, uploaded_at)} + + 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) + + logger.debug( + f"[6] 영상 메타데이터 구성 완료 - count={len(video_ids)}, sample={video_ids[:3]}" + ) + + # 7. 토큰 유효성 확인 및 자동 갱신 (만료 10분 전 갱신) + 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("[7] 토큰 유효성 확인 완료") + + # 8. YouTube Analytics API 호출 (8개 병렬) + 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("[8] YouTube Analytics API 호출 완료") + + # 9. TopContent 조립 (Analytics top_videos + DB lookup) + 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=DataProcessor._format_number(views), + engagement=f"{engagement_rate:.1f}%", + date=uploaded_at.strftime("%Y.%m.%d"), + ) + ) + + logger.debug(f"[9] TopContent 조립 완료 - count={len(top_content)}") + + # 10. 데이터 가공 (period_video_count=0 — API 무관 DB 집계값, 캐시에 포함하지 않음) + dashboard_data = processor.process( + raw_data, top_content, 0, mode=mode, end_date=end_date + ) + + logger.debug("[10] 데이터 가공 완료") + + # 11. Redis 캐싱 (TTL: 12시간) + # YouTube Analytics는 하루 1회 갱신 (PT 자정, 한국 시간 오후 5~8시) + # 48시간 지연된 데이터이므로 12시간 캐싱으로 API 호출 최소화 + # period_video_count는 캐시에 포함하지 않음 (DB 직접 집계, API 미사용) + cache_payload = json.dumps( + {"response": json.loads(dashboard_data.model_dump_json())} + ) + cache_success = await set_cache( + cache_key, + cache_payload, + ttl=43200, # 12시간 + ) + + if cache_success: + logger.debug(f"[CACHE SET] 캐시 저장 성공 - key={cache_key}") + else: + logger.warning(f"[CACHE SET] 캐시 저장 실패 - key={cache_key}") + + # 12. 업로드 영상 수 주입 (캐시 저장 후 — 항상 DB에서 직접 집계) + for metric in dashboard_data.content_metrics: + if metric.id == "uploaded-videos": + metric.value = str(period_video_count) + break + + logger.info( + f"[DASHBOARD] 통계 조회 완료 - " + f"user_uuid={current_user.user_uuid}, " + f"mode={mode}, period={period_desc}, videos={len(video_ids)}" + ) + + return dashboard_data + + +@router.delete( + "/cache", + response_model=CacheDeleteResponse, + summary="대시보드 캐시 삭제", + description=""" +대시보드 Redis 캐시를 삭제합니다. 인증 없이 호출 가능합니다. + +삭제 후 다음 `/stats` 요청 시 YouTube Analytics API를 새로 호출하여 최신 데이터를 반환합니다. + +## 사용 시나리오 +- 코드 배포 후 즉시 최신 데이터 반영이 필요할 때 +- 데이터 이상 발생 시 캐시 강제 갱신 + +## 캐시 키 구조 +`dashboard:{user_uuid}:{mode}` (mode: day 또는 month) + +## 파라미터 +- `user_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 | None = Query( + default=None, + description="대상 사용자 UUID. 미입력 시 전체 사용자 캐시 삭제", + ), +) -> CacheDeleteResponse: + """ + 대시보드 캐시 삭제 + + Args: + mode: 삭제할 캐시 모드 (day / month / all) + user_uuid: 대상 사용자 UUID (없으면 전체 삭제) + + Returns: + CacheDeleteResponse: 삭제된 캐시 키 개수 및 메시지 + """ + if user_uuid: + if mode == "all": + deleted = await delete_cache_pattern(f"dashboard:{user_uuid}:*") + message = f"전체 캐시 삭제 완료 ({deleted}개)" + else: + cache_key = f"dashboard:{user_uuid}:{mode}" + success = await delete_cache(cache_key) + deleted = 1 if success else 0 + message = f"{mode} 캐시 삭제 {'완료' if success else '실패 (키 없음)'}" + else: + deleted = await delete_cache_pattern("dashboard:*") + message = f"전체 사용자 캐시 삭제 완료 ({deleted}개)" + + logger.info( + f"[CACHE DELETE] user_uuid={user_uuid or 'ALL'}, mode={mode}, deleted={deleted}" + ) + + return CacheDeleteResponse(deleted_count=deleted, message=message) diff --git a/app/dashboard/exceptions.py b/app/dashboard/exceptions.py new file mode 100644 index 0000000..950f6f0 --- /dev/null +++ b/app/dashboard/exceptions.py @@ -0,0 +1,195 @@ +""" +Dashboard Exceptions + +Dashboard API 관련 예외 클래스를 정의합니다. +""" + +from fastapi import status + + +class DashboardException(Exception): + """Dashboard 기본 예외""" + + def __init__( + self, + message: str, + status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR, + code: str = "DASHBOARD_ERROR", + ): + self.message = message + self.status_code = status_code + self.code = code + super().__init__(self.message) + + +# ============================================================================= +# YouTube Analytics API 관련 예외 +# ============================================================================= + + +class YouTubeAPIException(DashboardException): + """YouTube Analytics API 관련 예외 기본 클래스""" + + def __init__( + self, + message: str = "YouTube Analytics API 호출 중 오류가 발생했습니다.", + status_code: int = status.HTTP_502_BAD_GATEWAY, + code: str = "YOUTUBE_API_ERROR", + ): + super().__init__(message, status_code, code) + + +class YouTubeAPIError(YouTubeAPIException): + """YouTube Analytics API 일반 오류""" + + def __init__(self, detail: str = ""): + error_message = "YouTube Analytics API 호출에 실패했습니다." + if detail: + error_message += f" ({detail})" + super().__init__( + message=error_message, + status_code=status.HTTP_502_BAD_GATEWAY, + code="YOUTUBE_API_FAILED", + ) + + +class YouTubeAuthError(YouTubeAPIException): + """YouTube 인증 실패""" + + def __init__(self, detail: str = ""): + error_message = "YouTube 인증에 실패했습니다. 계정 재연동이 필요합니다." + if detail: + error_message += f" ({detail})" + super().__init__( + message=error_message, + status_code=status.HTTP_401_UNAUTHORIZED, + code="YOUTUBE_AUTH_FAILED", + ) + + +class YouTubeQuotaExceededError(YouTubeAPIException): + """YouTube API 할당량 초과""" + + def __init__(self): + super().__init__( + message="YouTube API 일일 할당량이 초과되었습니다. 내일 다시 시도해주세요.", + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + code="YOUTUBE_QUOTA_EXCEEDED", + ) + + +class YouTubeDataNotFoundError(YouTubeAPIException): + """YouTube Analytics 데이터 없음""" + + def __init__(self, detail: str = ""): + error_message = "YouTube Analytics 데이터를 찾을 수 없습니다." + if detail: + error_message += f" ({detail})" + super().__init__( + message=error_message, + status_code=status.HTTP_404_NOT_FOUND, + code="YOUTUBE_DATA_NOT_FOUND", + ) + + +# ============================================================================= +# 계정 연동 관련 예외 +# ============================================================================= + + +class YouTubeAccountNotConnectedError(DashboardException): + """YouTube 계정 미연동""" + + def __init__(self): + super().__init__( + message="YouTube 계정이 연동되어 있지 않습니다. 먼저 YouTube 계정을 연동해주세요.", + status_code=status.HTTP_403_FORBIDDEN, + code="YOUTUBE_NOT_CONNECTED", + ) + + +class YouTubeAccountSelectionRequiredError(DashboardException): + """여러 YouTube 계정이 연동된 경우 계정 선택 필요""" + + def __init__(self): + super().__init__( + message="연결된 YouTube 계정이 여러 개입니다. social_account_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", + ) diff --git a/app/dashboard/migration.py b/app/dashboard/migration.py new file mode 100644 index 0000000..912b9ea --- /dev/null +++ b/app/dashboard/migration.py @@ -0,0 +1,112 @@ +""" +Dashboard Migration + +dashboard 테이블 초기화 및 기존 데이터 마이그레이션을 담당합니다. +서버 기동 시 create_db_tables() 이후 호출됩니다. +""" + +import logging + +from sqlalchemy import func, select, text +from sqlalchemy.dialects.mysql import insert + +from app.dashboard.models import Dashboard +from app.database.session import AsyncSessionLocal, engine +from app.social.models import SocialUpload +from app.user.models import SocialAccount + +logger = logging.getLogger(__name__) + + +async def _dashboard_table_exists() -> bool: + """dashboard 테이블 존재 여부 확인""" + async with engine.connect() as conn: + result = await conn.execute( + text( + "SELECT COUNT(*) FROM information_schema.tables " + "WHERE table_schema = DATABASE() AND table_name = 'dashboard'" + ) + ) + return result.scalar() > 0 + + +async def _dashboard_is_empty() -> bool: + """dashboard 테이블 데이터 존재 여부 확인""" + async with AsyncSessionLocal() as session: + result = await session.execute( + select(func.count()).select_from(Dashboard) + ) + return result.scalar() == 0 + + +async def _migrate_existing_data() -> None: + """ + SocialUpload(status=completed) → Dashboard 마이그레이션. + INSERT IGNORE로 중복 안전하게 삽입. + """ + async with AsyncSessionLocal() as session: + result = await session.execute( + select( + SocialUpload.user_uuid, + SocialUpload.platform, + SocialUpload.platform_video_id, + SocialUpload.platform_url, + SocialUpload.title, + SocialUpload.uploaded_at, + SocialAccount.platform_user_id, + ) + .join(SocialAccount, SocialUpload.social_account_id == SocialAccount.id) + .where( + SocialUpload.status == "completed", + SocialUpload.platform_video_id.isnot(None), + SocialUpload.uploaded_at.isnot(None), + SocialAccount.platform_user_id.isnot(None), + ) + ) + rows = result.all() + + if not rows: + logger.info("[DASHBOARD_MIGRATE] 마이그레이션 대상 없음") + return + + async with AsyncSessionLocal() as session: + for row in rows: + stmt = ( + insert(Dashboard) + .values( + user_uuid=row.user_uuid, + platform=row.platform, + platform_user_id=row.platform_user_id, + platform_video_id=row.platform_video_id, + platform_url=row.platform_url, + title=row.title, + uploaded_at=row.uploaded_at, + ) + .prefix_with("IGNORE") + ) + await session.execute(stmt) + await session.commit() + + logger.info(f"[DASHBOARD_MIGRATE] 마이그레이션 완료 - {len(rows)}건 삽입") + + +async def init_dashboard_table() -> None: + """ + dashboard 테이블 초기화 진입점. + + - 테이블이 없으면 생성 후 마이그레이션 + - 테이블이 있지만 비어있으면 마이그레이션 (DEBUG 모드에서 create_db_tables()가 빈 테이블 생성한 경우) + - 테이블이 있고 데이터도 있으면 스킵 + """ + if not await _dashboard_table_exists(): + logger.info("[DASHBOARD_MIGRATE] dashboard 테이블 없음 - 생성 및 마이그레이션 시작") + async with engine.begin() as conn: + await conn.run_sync( + lambda c: Dashboard.__table__.create(c, checkfirst=True) + ) + await _migrate_existing_data() + elif await _dashboard_is_empty(): + logger.info("[DASHBOARD_MIGRATE] dashboard 테이블 비어있음 - 마이그레이션 시작") + await _migrate_existing_data() + else: + logger.info("[DASHBOARD_MIGRATE] dashboard 테이블 이미 존재 - 스킵") diff --git a/app/dashboard/models.py b/app/dashboard/models.py new file mode 100644 index 0000000..3e8cfed --- /dev/null +++ b/app/dashboard/models.py @@ -0,0 +1,134 @@ +""" +Dashboard Models + +대시보드 전용 SQLAlchemy 모델을 정의합니다. +""" + +from datetime import datetime +from typing import Optional + +from sqlalchemy import BigInteger, DateTime, Index, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database.session import Base + + +class Dashboard(Base): + """ + 채널별 영상 업로드 기록 테이블 + + YouTube 업로드 완료 시 채널 ID(platform_user_id)와 함께 기록합니다. + SocialUpload.social_account_id는 재연동 시 변경되므로, + 이 테이블로 채널 기준 안정적인 영상 필터링을 제공합니다. + + Attributes: + id: 고유 식별자 (자동 증가) + user_uuid: 사용자 UUID (User.user_uuid 참조) + platform: 플랫폼 (youtube/instagram) + platform_user_id: 채널 ID (재연동 후에도 불변) + platform_video_id: 영상 ID + platform_url: 영상 URL + title: 영상 제목 + uploaded_at: SocialUpload 완료 시각 + created_at: 레코드 생성 시각 + """ + + __tablename__ = "dashboard" + __table_args__ = ( + UniqueConstraint( + "platform_video_id", + "platform_user_id", + name="uq_vcu_video_channel", + ), + Index("idx_vcu_user_platform", "user_uuid", "platform_user_id"), + Index("idx_vcu_uploaded_at", "uploaded_at"), + { + "mysql_engine": "InnoDB", + "mysql_charset": "utf8mb4", + "mysql_collate": "utf8mb4_unicode_ci", + }, + ) + + # ========================================================================== + # 기본 식별자 + # ========================================================================== + id: Mapped[int] = mapped_column( + BigInteger, + primary_key=True, + nullable=False, + autoincrement=True, + comment="고유 식별자", + ) + + # ========================================================================== + # 관계 필드 + # ========================================================================== + user_uuid: Mapped[str] = mapped_column( + String(36), + nullable=False, + comment="사용자 UUID (User.user_uuid 참조)", + ) + + # ========================================================================== + # 플랫폼 정보 + # ========================================================================== + platform: Mapped[str] = mapped_column( + String(20), + nullable=False, + comment="플랫폼 (youtube/instagram)", + ) + + platform_user_id: Mapped[str] = mapped_column( + String(100), + nullable=False, + comment="채널 ID (재연동 후에도 불변)", + ) + + # ========================================================================== + # 플랫폼 결과 + # ========================================================================== + platform_video_id: Mapped[str] = mapped_column( + String(100), + nullable=False, + comment="영상 ID", + ) + + platform_url: Mapped[Optional[str]] = mapped_column( + String(500), + nullable=True, + comment="영상 URL", + ) + + # ========================================================================== + # 메타데이터 + # ========================================================================== + title: Mapped[str] = mapped_column( + String(200), + nullable=False, + comment="영상 제목", + ) + + # ========================================================================== + # 시간 정보 + # ========================================================================== + uploaded_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + comment="SocialUpload 완료 시각", + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + comment="레코드 생성 시각", + ) + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/app/dashboard/redis_cache.py b/app/dashboard/redis_cache.py new file mode 100644 index 0000000..3a6b416 --- /dev/null +++ b/app/dashboard/redis_cache.py @@ -0,0 +1,173 @@ +""" +Redis 캐싱 유틸리티 + +Dashboard API 성능 최적화를 위한 Redis 캐싱 기능을 제공합니다. +YouTube Analytics API 호출 결과를 캐싱하여 중복 요청을 방지합니다. +""" + +from typing import Optional + +from redis.asyncio import Redis + +from app.utils.logger import get_logger +from config import db_settings + +logger = get_logger("redis_cache") + +# Dashboard 전용 Redis 클라이언트 (db=3 사용) +_cache_client = Redis( + host=db_settings.REDIS_HOST, + port=db_settings.REDIS_PORT, + db=3, + decode_responses=True, +) + + +async def get_cache(key: str) -> Optional[str]: + """ + Redis 캐시에서 값을 조회합니다. + + Args: + key: 캐시 키 + + Returns: + 캐시된 값 (문자열) 또는 None (캐시 미스) + + Example: + >>> cached_data = await get_cache("dashboard:user123:2026-01-01:2026-12-31") + >>> if cached_data: + >>> return json.loads(cached_data) + """ + try: + logger.debug(f"[GET_CACHE] 캐시 조회 시작 - key: {key}") + value = await _cache_client.get(key) + + if value: + logger.debug(f"[GET_CACHE] 캐시 HIT - key: {key}") + else: + logger.debug(f"[GET_CACHE] 캐시 MISS - key: {key}") + + return value + except Exception as e: + logger.error(f"[GET_CACHE] 캐시 조회 실패 - key: {key}, error: {e}") + return None # 캐시 실패 시 None 반환 (원본 데이터 조회하도록 유도) + + +async def set_cache(key: str, value: str, ttl: int = 43200) -> bool: + """ + Redis 캐시에 값을 저장합니다. + + Args: + key: 캐시 키 + value: 저장할 값 (문자열) + ttl: 캐시 만료 시간 (초). 기본값: 43200초 (12시간) + + Returns: + 성공 여부 + + Example: + >>> import json + >>> data = {"views": 1000, "likes": 50} + >>> await set_cache("dashboard:user123:2026-01-01:2026-12-31", json.dumps(data), ttl=3600) + """ + try: + logger.debug(f"[SET_CACHE] 캐시 저장 시작 - key: {key}, ttl: {ttl}s") + await _cache_client.setex(key, ttl, value) + logger.debug(f"[SET_CACHE] 캐시 저장 성공 - key: {key}") + return True + except Exception as e: + logger.error(f"[SET_CACHE] 캐시 저장 실패 - key: {key}, error: {e}") + return False + + +async def delete_cache(key: str) -> bool: + """ + Redis 캐시에서 값을 삭제합니다. + + Args: + key: 삭제할 캐시 키 + + Returns: + 성공 여부 + + Example: + >>> await delete_cache("dashboard:user123:2026-01-01:2026-12-31") + """ + try: + logger.debug(f"[DELETE_CACHE] 캐시 삭제 시작 - key: {key}") + deleted_count = await _cache_client.delete(key) + logger.debug( + f"[DELETE_CACHE] 캐시 삭제 완료 - key: {key}, deleted: {deleted_count}" + ) + return deleted_count > 0 + except Exception as e: + logger.error(f"[DELETE_CACHE] 캐시 삭제 실패 - key: {key}, error: {e}") + return False + + +async def delete_cache_pattern(pattern: str) -> int: + """ + 패턴에 매칭되는 모든 캐시 키를 삭제합니다. + + Args: + pattern: 삭제할 키 패턴 (예: "dashboard:user123:*") + + Returns: + 삭제된 키 개수 + + Example: + >>> # 특정 사용자의 모든 대시보드 캐시 삭제 + >>> deleted = await delete_cache_pattern("dashboard:user123:*") + >>> print(f"{deleted}개의 캐시 삭제됨") + + Note: + 대량의 키 삭제 시 성능에 영향을 줄 수 있으므로 주의해서 사용하세요. + """ + try: + logger.debug(f"[DELETE_CACHE_PATTERN] 패턴 캐시 삭제 시작 - pattern: {pattern}") + + # 패턴에 매칭되는 모든 키 조회 + keys = [] + async for key in _cache_client.scan_iter(match=pattern): + keys.append(key) + + if not keys: + logger.debug(f"[DELETE_CACHE_PATTERN] 삭제할 키 없음 - pattern: {pattern}") + return 0 + + # 모든 키 삭제 + deleted_count = await _cache_client.delete(*keys) + logger.debug( + f"[DELETE_CACHE_PATTERN] 패턴 캐시 삭제 완료 - " + f"pattern: {pattern}, deleted: {deleted_count}" + ) + return deleted_count + + except Exception as e: + logger.error( + f"[DELETE_CACHE_PATTERN] 패턴 캐시 삭제 실패 - pattern: {pattern}, error: {e}" + ) + return 0 + + +async def close_cache_client(): + """ + Redis 클라이언트 연결을 종료합니다. + + 애플리케이션 종료 시 호출되어야 합니다. + main.py의 shutdown 이벤트 핸들러에서 사용하세요. + + Example: + >>> # main.py + >>> @app.on_event("shutdown") + >>> async def shutdown_event(): + >>> await close_cache_client() + """ + try: + logger.info("[CLOSE_CACHE_CLIENT] Redis 캐시 클라이언트 종료 중...") + await _cache_client.close() + logger.info("[CLOSE_CACHE_CLIENT] Redis 캐시 클라이언트 종료 완료") + except Exception as e: + logger.error( + f"[CLOSE_CACHE_CLIENT] Redis 캐시 클라이언트 종료 실패 - error: {e}" + ) diff --git a/app/dashboard/schemas/__init__.py b/app/dashboard/schemas/__init__.py new file mode 100644 index 0000000..0044ba9 --- /dev/null +++ b/app/dashboard/schemas/__init__.py @@ -0,0 +1,33 @@ +""" +Dashboard Schemas + +Dashboard API의 요청/응답 스키마를 정의합니다. +""" + +from app.dashboard.schemas.dashboard_schema import ( + AudienceData, + CacheDeleteResponse, + ConnectedAccount, + ConnectedAccountsResponse, + ContentMetric, + DailyData, + DashboardResponse, + MonthlyData, + PlatformData, + PlatformMetric, + TopContent, +) + +__all__ = [ + "ConnectedAccount", + "ConnectedAccountsResponse", + "ContentMetric", + "DailyData", + "MonthlyData", + "TopContent", + "AudienceData", + "PlatformMetric", + "PlatformData", + "DashboardResponse", + "CacheDeleteResponse", +] diff --git a/app/dashboard/schemas/dashboard_schema.py b/app/dashboard/schemas/dashboard_schema.py new file mode 100644 index 0000000..8aab0d4 --- /dev/null +++ b/app/dashboard/schemas/dashboard_schema.py @@ -0,0 +1,352 @@ +""" +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 문자열 (예: "label_en") + + Returns: + camelCase 문자열 (예: "labelEn") + + 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-engagement") + label: 한글 라벨 (예: "총 조회수") + label_en: 영문 라벨 (예: "Total Views") + value: 포맷팅된 값 (예: "1.2M", "8.2%") + trend: 증감 추세 값 (예: 12.5 → 12.5% 증가) + trend_direction: 추세 방향 ("up" 또는 "down") + + Example: + >>> metric = ContentMetric( + ... id="total-views", + ... label="총 조회수", + ... label_en="Total Views", + ... value="1.2M", + ... trend=12.5, + ... trend_direction="up" + ... ) + """ + + id: str + label: str + label_en: str + value: str + 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: 포맷팅된 조회수 (예: "125K") + engagement: 참여율 (예: "8.2%") + date: 업로드 날짜 (예: "2026.01.15") + + Example: + >>> content = TopContent( + ... id="video-id-1", + ... title="힐링 영상", + ... thumbnail="https://i.ytimg.com/...", + ... platform="youtube", + ... views="125K", + ... engagement="8.2%", + ... date="2026.01.15" + ... ) + """ + + id: str + title: str + thumbnail: str + platform: Literal["youtube", "instagram"] + views: str + 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: 성별 조회수 + {"male": 45000, "female": 55000} + top_regions: 상위 지역 리스트 + [{"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 PlatformMetric(BaseModel): + """플랫폼별 메트릭 + + 플랫폼별 세부 지표입니다 (예: 구독자 증가, 구독 취소). + + Attributes: + id: 메트릭 고유 ID (예: "subscribers-gained") + label: 지표명 (예: "구독자 증가") + value: 포맷팅된 값 (예: "1.2K") + unit: 단위 (선택, 예: "명", "%") + trend: 증감 추세 값 + trend_direction: 추세 방향 ("up" 또는 "down") + + Example: + >>> metric = PlatformMetric( + ... id="subscribers-gained", + ... label="구독자 증가", + ... value="1.2K", + ... trend=8.5, + ... trend_direction="up" + ... ) + """ + + id: str + label: str + value: str + unit: Optional[str] = None + trend: float + trend_direction: Literal["up", "down", "-"] = Field(alias="trendDirection") + + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + ) + + +class PlatformData(BaseModel): + """플랫폼별 데이터 + + 플랫폼별로 그룹화된 메트릭 데이터입니다. + + Attributes: + platform: 플랫폼 종류 ("youtube" 또는 "instagram") + display_name: 표시할 플랫폼 이름 (예: "YouTube") + metrics: 플랫폼별 메트릭 리스트 + + Example: + >>> data = PlatformData( + ... platform="youtube", + ... display_name="YouTube", + ... metrics=[ + ... PlatformMetric( + ... id="subscribers", + ... label="구독자 증가", + ... value="1.2K", + ... trend=8.5, + ... trend_direction="up" + ... ) + ... ] + ... ) + """ + + platform: Literal["youtube", "instagram"] + display_name: str = Field(alias="displayName") + metrics: list[PlatformMetric] + + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + ) + + +class DashboardResponse(BaseModel): + """대시보드 전체 응답 + + GET /api/dashboard/stats 엔드포인트의 전체 응답 스키마입니다. + 모든 대시보드 데이터를 포함합니다. + + Attributes: + content_metrics: KPI 지표 카드 리스트 + monthly_data: 월별 추이 데이터 (전년 대비) + top_content: 인기 영상 리스트 + audience_data: 시청자 분석 데이터 + platform_data: 플랫폼별 메트릭 데이터 + + Example: + >>> response = DashboardResponse( + ... content_metrics=[...], + ... monthly_data=[...], + ... top_content=[...], + ... audience_data=AudienceData(...), + ... platform_data=[...] + ... ) + >>> 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") + platform_data: list[PlatformData] = Field(default=[], alias="platformData") + + model_config = ConfigDict( + alias_generator=to_camel, + populate_by_name=True, + ) + + +class ConnectedAccount(BaseModel): + """연결된 소셜 계정 정보 + + Attributes: + id: SocialAccount 테이블 ID (계정 선택 시 이 값을 social_account_id로 전달) + platform: 플랫폼 (예: "youtube") + platform_username: 플랫폼 사용자명 (예: "@channelname") + platform_user_id: 플랫폼 사용자 고유 ID + channel_title: YouTube 채널 제목 (platform_data에서 추출) + 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 diff --git a/app/dashboard/services/__init__.py b/app/dashboard/services/__init__.py new file mode 100644 index 0000000..6559906 --- /dev/null +++ b/app/dashboard/services/__init__.py @@ -0,0 +1,13 @@ +""" +Dashboard Services + +YouTube Analytics API 연동 및 데이터 가공 서비스를 제공합니다. +""" + +from app.dashboard.services.data_processor import DataProcessor +from app.dashboard.services.youtube_analytics import YouTubeAnalyticsService + +__all__ = [ + "YouTubeAnalyticsService", + "DataProcessor", +] diff --git a/app/dashboard/services/data_processor.py b/app/dashboard/services/data_processor.py new file mode 100644 index 0000000..ae4973a --- /dev/null +++ b/app/dashboard/services/data_processor.py @@ -0,0 +1,565 @@ +""" +YouTube Analytics 데이터 가공 프로세서 + +YouTube Analytics API의 원본 데이터를 프론트엔드용 Pydantic 스키마로 변환합니다. +""" + +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") + + +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", {}), + ) + 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", {}), + raw_data.get("region", {}), + ) + 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="조회수", + label_en="Total Views", + value=self._format_number(int(views)), + trend=views_trend, + trend_direction=views_dir, + ), + ContentMetric( + id="total-watch-time", + label="시청시간", + label_en="Watch Time", + value=f"{round(estimated_minutes_watched / 60, 1)}시간", + trend=watch_trend, + trend_direction=watch_dir, + ), + ContentMetric( + id="avg-view-duration", + label="평균 시청시간", + label_en="Avg. View Duration", + value=f"{round(average_view_duration / 60)}분", + trend=duration_trend, + trend_direction=duration_dir, + ), + ContentMetric( + id="new-subscribers", + label="신규 구독자", + label_en="New Subscribers", + value=self._format_number(int(subscribers_gained)), + trend=subs_trend, + trend_direction=subs_dir, + ), + ContentMetric( + id="likes", + label="좋아요", + label_en="Likes", + value=self._format_number(int(likes)), + trend=likes_trend, + trend_direction=likes_dir, + ), + ContentMetric( + id="comments", + label="댓글", + label_en="Comments", + value=self._format_number(int(comments)), + trend=comments_trend, + trend_direction=comments_dir, + ), + ContentMetric( + id="shares", + label="공유", + label_en="Shares", + value=self._format_number(int(shares)), + trend=shares_trend, + trend_direction=shares_dir, + ), + ContentMetric( + id="uploaded-videos", + label="업로드 영상", + label_en="Uploaded Videos", + value=str(period_video_count), + trend=0.0, + trend_direction="up", + ), + ] + + def _merge_monthly_data( + self, + data_recent: dict[str, Any], + data_previous: dict[str, Any], + ) -> list[MonthlyData]: + """최근 12개월과 이전 12개월의 월별 데이터를 병합 + + 최근 12개월 대비 이전 12개월의 월별 조회수 비교 차트를 위한 데이터를 생성합니다. + 실제 API 응답의 월 데이터를 기준으로 매핑합니다. + + Args: + data_recent: 최근 12개월 월별 조회수 데이터 + rows = [["2026-01", 150000], ["2026-02", 180000], ...] + data_previous: 이전 12개월 월별 조회수 데이터 + rows = [["2025-01", 120000], ["2025-02", 140000], ...] + + Returns: + list[MonthlyData]: 월별 비교 데이터 (최대 12개) + """ + logger.debug("[DataProcessor._merge_monthly_data] START") + + rows_recent = data_recent.get("rows", []) + rows_previous = data_previous.get("rows", []) + + # 월별 맵 생성: {"2025-02": 150000, "2025-03": 180000} + 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} + + # 최근 기간의 월 키만 기준으로 정렬 (24개 합집합 방지) + # 각 월의 이전 연도 키는 1년 전으로 계산: "2025-02" → "2024-02" + recent_months = sorted(map_recent.keys()) + + # 월별 데이터 생성 + result = [] + for month_key in recent_months: + year, month = month_key.split("-") + month_num = int(month) + month_label = f"{month_num}월" + + # 이전 연도 동일 월: "2025-02" → "2024-02" + prev_year_key = f"{int(year) - 1}-{month}" + + result.append( + MonthlyData( + month=month_label, + this_year=map_recent.get(month_key, 0), + last_year=map_previous.get(prev_year_key, 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, int] = {} + gender_map = {"male": 0, "female": 0} + + for row in demo_rows: + if len(row) < 3: + continue + + age_group = row[0] # "age18-24" + gender = row[1] # "male" or "female" + views = row[2] + + # 연령대별 집계 (age18-24 → 18-24) + age_label = age_group.replace("age", "") + age_map[age_label] = age_map.get(age_label, 0) + views + + # 성별 집계 + if gender in gender_map: + gender_map[gender] += views + + # 연령대별 비율 계산 + total_demo_views = sum(age_map.values()) + age_groups = [ + { + "label": age, + "percentage": int( + (count / total_demo_views * 100) if total_demo_views > 0 else 0 + ), + } + for age, count in sorted(age_map.items()) + ] + + # === 지역 데이터 처리 === + geo_rows = geography_data.get("rows", []) + total_geo_views = sum(row[1] for row in geo_rows if len(row) >= 2) + + top_regions = [ + { + "region": self._translate_country_code(row[0]), + "percentage": int( + (row[1] / total_geo_views * 100) if total_geo_views > 0 else 0 + ), + } + for row in geo_rows[:5] # 상위 5개 + if len(row) >= 2 + ] + + 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 _format_number(num: int) -> str: + """숫자 포맷팅 (1234567 → "1.2M") + + 조회수, 구독자 수 등 큰 숫자를 읽기 쉽게 포맷팅합니다. + + Args: + num: 원본 숫자 + + Returns: + str: 포맷팅된 문자열 + - 1,000,000 이상: "1.2M" + - 1,000 이상: "12.5K" + - 1,000 미만: "123" + + Example: + >>> _format_number(1234567) + "1.2M" + >>> _format_number(12345) + "12.3K" + >>> _format_number(123) + "123" + """ + if num >= 1_000_000: + return f"{num / 1_000_000:.1f}M" + elif num >= 1_000: + return f"{num / 1_000:.1f}K" + else: + return str(num) + + @staticmethod + def _format_duration(seconds: int) -> str: + """초를 M:SS 형식으로 변환 (평균 시청 시간 표시용) + + Args: + seconds: 초 단위 시간 + + Returns: + str: M:SS 형식 문자열 + - 204초 → "3:24" + - 65초 → "1:05" + - 45초 → "0:45" + """ + minutes = seconds // 60 + secs = seconds % 60 + return f"{minutes}:{secs:02d}" + + @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") + "미국" + """ + country_map = { + "KR": "대한민국", + "US": "미국", + "JP": "일본", + "CN": "중국", + "GB": "영국", + "DE": "독일", + "FR": "프랑스", + "CA": "캐나다", + "AU": "호주", + "IN": "인도", + "ID": "인도네시아", + "TH": "태국", + "VN": "베트남", + "PH": "필리핀", + "MY": "말레이시아", + "SG": "싱가포르", + "TW": "대만", + "HK": "홍콩", + "BR": "브라질", + "MX": "멕시코", + } + return country_map.get(code, code) diff --git a/app/dashboard/services/youtube_analytics.py b/app/dashboard/services/youtube_analytics.py new file mode 100644 index 0000000..9211344 --- /dev/null +++ b/app/dashboard/services/youtube_analytics.py @@ -0,0 +1,497 @@ +""" +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 액세스 토큰 + + Returns: + dict[str, Any]: API 응답 데이터 + - kpi: KPI 메트릭 (조회수, 좋아요, 댓글 등) + - monthly_recent: 최근 12개월 월별 조회수 + - monthly_previous: 이전 12개월 월별 조회수 + - top_videos: 인기 영상 TOP 4 + - demographics: 연령/성별 분포 + - region: 지역별 조회수 + + 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/6] 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(video_ids, 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(video_ids, start_date, end_date, access_token), + ] + + # 병렬 실행 + results = await asyncio.gather(*tasks, return_exceptions=True) + + # 에러 체크 (YouTubeAuthError, YouTubeQuotaExceededError는 원형 그대로 전파) + 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 + 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] + + Note: + annotationClickThroughRate는 2019년 annotations 기능 제거로 deprecated. + """ + 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, + video_ids: list[str], + start_date: str, + end_date: str, + access_token: str, + ) -> dict[str, Any]: + """지역별 조회수 조회 + + 지역별 조회수 분포를 조회합니다 (상위 5개). + + 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 = [["KR", 1000000], ["US", 500000], ...] + 조회수 내림차순으로 정렬된 상위 5개 국가 + """ + logger.debug("[YouTubeAnalyticsService._fetch_country] START") + + params = { + "ids": "channel==MINE", + "startDate": start_date, + "endDate": end_date, + "dimensions": "country", + "metrics": "views", + # "filters": f"video=={','.join(video_ids)}", + "sort": "-views", + "maxResults": "5", + } + + 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}") diff --git a/app/dashboard/tasks.py b/app/dashboard/tasks.py new file mode 100644 index 0000000..7afdc48 --- /dev/null +++ b/app/dashboard/tasks.py @@ -0,0 +1,71 @@ +""" +Dashboard Background Tasks + +업로드 완료 시 Dashboard 테이블에 레코드를 삽입하는 백그라운드 태스크입니다. +""" + +import logging + +from sqlalchemy import select +from sqlalchemy.dialects.mysql import insert + +from app.dashboard.models import Dashboard +from app.database.session import BackgroundSessionLocal +from app.social.models import SocialUpload +from app.user.models import SocialAccount + +logger = logging.getLogger(__name__) + + +async def insert_dashboard(upload_id: int) -> None: + """ + Dashboard 레코드 삽입 + + SocialUpload(id=upload_id) 완료 데이터를 DB에서 조회하여 Dashboard에 삽입합니다. + UniqueConstraint(platform_video_id, platform_user_id) 충돌 시 스킵(INSERT IGNORE). + """ + try: + async with BackgroundSessionLocal() as session: + result = await session.execute( + select( + SocialUpload.user_uuid, + SocialUpload.platform, + SocialUpload.platform_video_id, + SocialUpload.platform_url, + SocialUpload.title, + SocialUpload.uploaded_at, + SocialAccount.platform_user_id, + ) + .join(SocialAccount, SocialUpload.social_account_id == SocialAccount.id) + .where(SocialUpload.id == upload_id) + ) + row = result.one_or_none() + + if not row: + logger.warning(f"[dashboard] upload_id={upload_id} 데이터 없음") + return + + stmt = ( + insert(Dashboard) + .values( + user_uuid=row.user_uuid, + platform=row.platform, + platform_user_id=row.platform_user_id, + platform_video_id=row.platform_video_id, + platform_url=row.platform_url, + title=row.title, + uploaded_at=row.uploaded_at, + ) + .prefix_with("IGNORE") + ) + await session.execute(stmt) + await session.commit() + + logger.info( + f"[dashboard] 삽입 완료 - " + f"upload_id={upload_id}, platform_video_id={row.platform_video_id}" + ) + except Exception as e: + logger.error( + f"[dashboard] 삽입 실패 - upload_id={upload_id}, error={e}" + ) diff --git a/app/database/session.py b/app/database/session.py index 1412667..6fc7a88 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -79,6 +79,7 @@ async def create_db_tables(): from app.video.models import Video # noqa: F401 from app.sns.models import SNSUploadTask # noqa: F401 from app.social.models import SocialUpload # noqa: F401 + from app.dashboard.models import Dashboard # noqa: F401 # 생성할 테이블 목록 tables_to_create = [ @@ -94,6 +95,7 @@ async def create_db_tables(): SNSUploadTask.__table__, SocialUpload.__table__, MarketingIntel.__table__, + Dashboard.__table__, ] logger.info("Creating database tables...") diff --git a/app/social/constants.py b/app/social/constants.py index 2899c7e..fda01bf 100644 --- a/app/social/constants.py +++ b/app/social/constants.py @@ -91,6 +91,7 @@ PLATFORM_CONFIG = { YOUTUBE_SCOPES = [ "https://www.googleapis.com/auth/youtube.upload", # 영상 업로드 "https://www.googleapis.com/auth/youtube.readonly", # 채널 정보 읽기 + "https://www.googleapis.com/auth/yt-analytics.readonly", # 대시보드 "https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필 ] diff --git a/app/social/worker/upload_task.py b/app/social/worker/upload_task.py index 6e48f89..f2659a6 100644 --- a/app/social/worker/upload_task.py +++ b/app/social/worker/upload_task.py @@ -18,6 +18,7 @@ from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError from config import social_upload_settings +from app.dashboard.tasks import insert_dashboard from app.database.session import BackgroundSessionLocal from app.social.constants import SocialPlatform, UploadStatus from app.social.exceptions import TokenExpiredError, UploadError, UploadQuotaExceededError @@ -318,6 +319,7 @@ async def process_social_upload(upload_id: int) -> None: f"platform_video_id: {result.platform_video_id}, " f"url: {result.platform_url}" ) + await insert_dashboard(upload_id) else: retry_count = await _increment_retry_count(upload_id) diff --git a/main.py b/main.py index db7b88d..95aca1d 100644 --- a/main.py +++ b/main.py @@ -12,6 +12,7 @@ from app.database.session import engine from app.user.models import User, RefreshToken # noqa: F401 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.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 @@ -219,6 +220,34 @@ tags_metadata = [ - `processing`: 플랫폼에서 처리 중 - `completed`: 업로드 완료 - `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개 이상의 영상이 업로드되어 있어야 합니다 """, }, { @@ -361,6 +390,7 @@ app.include_router(archive_router) # Archive API 라우터 추가 app.include_router(social_oauth_router, prefix="/social") # Social OAuth 라우터 추가 app.include_router(social_upload_router, prefix="/social") # Social Upload 라우터 추가 app.include_router(sns_router) # SNS API 라우터 추가 +app.include_router(dashboard_router) # Dashboard API 라우터 추가 # DEBUG 모드에서만 테스트 라우터 등록 if prj_settings.DEBUG: From a0c352f5672b45f1ddee0c8ea475259fb2ba3d60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 24 Feb 2026 17:12:04 +0900 Subject: [PATCH 2/5] =?UTF-8?q?docs=EC=97=90=20dashboard=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/dashboard/api/routers/v1/dashboard.py | 35 +++++- app/dashboard/services/data_processor.py | 22 ++-- docs/dashboard/YouTube Analytics API.md | 119 ++++++++++++++++++ .../migration_add_dashboard.sql | 77 ++++++++++++ 4 files changed, 240 insertions(+), 13 deletions(-) create mode 100644 docs/dashboard/YouTube Analytics API.md create mode 100644 docs/database-schema/migration_add_dashboard.sql diff --git a/app/dashboard/api/routers/v1/dashboard.py b/app/dashboard/api/routers/v1/dashboard.py index 3033e85..c38be32 100644 --- a/app/dashboard/api/routers/v1/dashboard.py +++ b/app/dashboard/api/routers/v1/dashboard.py @@ -176,6 +176,9 @@ async def get_dashboard_stats( end_dt = today - timedelta(days=2) kpi_end_dt = end_dt start_dt = end_dt - timedelta(days=29) + # 이전 30일 (YouTube API day_previous와 동일 기준) + prev_start_dt = start_dt - timedelta(days=30) + prev_kpi_end_dt = kpi_end_dt - timedelta(days=30) period_desc = "최근 30일" else: # mode == "month" # 월별 차트: dimensions=month API는 YYYY-MM-01 형식 필요 @@ -191,6 +194,12 @@ async def get_dashboard_stats( else: start_year = end_dt.year start_dt = date(start_year, start_month, 1) + # 이전 12개월 (YouTube API previous와 동일 기준 — 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: # 윤년 2/29 → 이전 연도 2/28 + prev_kpi_end_dt = kpi_end_dt.replace(year=kpi_end_dt.year - 1, day=28) period_desc = "최근 12개월" start_date = start_dt.strftime("%Y-%m-%d") @@ -263,7 +272,23 @@ async def get_dashboard_stats( ) ) period_video_count = count_result.scalar() or 0 - logger.debug(f"[3] 기간 내 업로드 영상 수 - count={period_video_count}") + + # 이전 기간 업로드 영상 수 조회 (trend 계산용) + 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 + logger.debug( + f"[3] 기간 내 업로드 영상 수 - current={period_video_count}, prev={prev_period_video_count}" + ) # 4. Redis 캐시 조회 # platform_user_id 기준 캐시 키: 재연동해도 채널 ID는 불변 → 캐시 유지됨 @@ -278,6 +303,9 @@ async def get_dashboard_stats( for metric in response.content_metrics: if metric.id == "uploaded-videos": metric.value = str(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 return response except (json.JSONDecodeError, KeyError): @@ -404,10 +432,13 @@ async def get_dashboard_stats( else: logger.warning(f"[CACHE SET] 캐시 저장 실패 - key={cache_key}") - # 12. 업로드 영상 수 주입 (캐시 저장 후 — 항상 DB에서 직접 집계) + # 12. 업로드 영상 수 및 trend 주입 (캐시 저장 후 — 항상 DB에서 직접 집계) for metric in dashboard_data.content_metrics: if metric.id == "uploaded-videos": metric.value = str(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 logger.info( diff --git a/app/dashboard/services/data_processor.py b/app/dashboard/services/data_processor.py index ae4973a..f808a57 100644 --- a/app/dashboard/services/data_processor.py +++ b/app/dashboard/services/data_processor.py @@ -195,9 +195,9 @@ class DataProcessor: 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}%)" + f"views={views}({views_trend:+.1f}), " + f"watch_time={estimated_minutes_watched}min({watch_trend:+.1f}), " + f"subscribers={subscribers_gained}({subs_trend:+.1f})" ) return [ @@ -206,7 +206,7 @@ class DataProcessor: label="조회수", label_en="Total Views", value=self._format_number(int(views)), - trend=views_trend, + trend=round(float(views_trend), 1), trend_direction=views_dir, ), ContentMetric( @@ -214,7 +214,7 @@ class DataProcessor: label="시청시간", label_en="Watch Time", value=f"{round(estimated_minutes_watched / 60, 1)}시간", - trend=watch_trend, + trend=round(watch_trend / 60, 1), trend_direction=watch_dir, ), ContentMetric( @@ -222,7 +222,7 @@ class DataProcessor: label="평균 시청시간", label_en="Avg. View Duration", value=f"{round(average_view_duration / 60)}분", - trend=duration_trend, + trend=round(duration_trend / 60, 1), trend_direction=duration_dir, ), ContentMetric( @@ -230,7 +230,7 @@ class DataProcessor: label="신규 구독자", label_en="New Subscribers", value=self._format_number(int(subscribers_gained)), - trend=subs_trend, + trend=round(float(subs_trend), 1), trend_direction=subs_dir, ), ContentMetric( @@ -238,7 +238,7 @@ class DataProcessor: label="좋아요", label_en="Likes", value=self._format_number(int(likes)), - trend=likes_trend, + trend=round(float(likes_trend), 1), trend_direction=likes_dir, ), ContentMetric( @@ -246,7 +246,7 @@ class DataProcessor: label="댓글", label_en="Comments", value=self._format_number(int(comments)), - trend=comments_trend, + trend=round(float(comments_trend), 1), trend_direction=comments_dir, ), ContentMetric( @@ -254,7 +254,7 @@ class DataProcessor: label="공유", label_en="Shares", value=self._format_number(int(shares)), - trend=shares_trend, + trend=round(float(shares_trend), 1), trend_direction=shares_dir, ), ContentMetric( @@ -263,7 +263,7 @@ class DataProcessor: label_en="Uploaded Videos", value=str(period_video_count), trend=0.0, - trend_direction="up", + trend_direction="-", ), ] diff --git a/docs/dashboard/YouTube Analytics API.md b/docs/dashboard/YouTube Analytics API.md new file mode 100644 index 0000000..e52e37f --- /dev/null +++ b/docs/dashboard/YouTube Analytics API.md @@ -0,0 +1,119 @@ +# 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. 자주 쓰는 조합 (Best Practice) + +1. **프로젝트 전체 요약 (KPI)** + - `dimensions`: (없음) + - `metrics`: `views,likes,estimatedRevenue` + - `filters`: `video==ID1,ID2...` +2. **일별 성장 그래프 (Line Chart)** + - `dimensions`: `day` + - `metrics`: `views` + - `filters`: `video==ID1,ID2...` + - `sort`: `day` +3. **인기 영상 랭킹 (Table)** + - `dimensions`: `video` + - `metrics`: `views,averageViewDuration` + - `filters`: `video==ID1,ID2...` + - `sort`: `-views` + +--- + +## 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)** 발생 +- 최신 데이터가 필요한 경우 이 점을 고려해야 함 + diff --git a/docs/database-schema/migration_add_dashboard.sql b/docs/database-schema/migration_add_dashboard.sql new file mode 100644 index 0000000..755a4f8 --- /dev/null +++ b/docs/database-schema/migration_add_dashboard.sql @@ -0,0 +1,77 @@ +-- =================================================================== +-- dashboard 테이블 생성 마이그레이션 +-- 채널별 영상 업로드 기록을 저장하는 테이블 +-- SocialUpload.social_account_id는 재연동 시 변경되므로, +-- 채널 ID(platform_user_id) 기준으로 안정적인 영상 필터링을 제공합니다. +-- 생성일: 2026-02-24 +-- =================================================================== + +-- dashboard 테이블 생성 +CREATE TABLE IF NOT EXISTS dashboard ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '고유 식별자', + + -- 관계 필드 + user_uuid VARCHAR(36) NOT NULL COMMENT '사용자 UUID (user.user_uuid 참조)', + + -- 플랫폼 정보 + platform VARCHAR(20) NOT NULL COMMENT '플랫폼 (youtube, instagram)', + platform_user_id VARCHAR(100) NOT NULL COMMENT '채널 ID (재연동 후에도 불변)', + + -- 플랫폼 결과 + platform_video_id VARCHAR(100) NOT NULL COMMENT '플랫폼에서 부여한 영상 ID', + platform_url VARCHAR(500) NULL COMMENT '플랫폼에서의 영상 URL', + + -- 메타데이터 + title VARCHAR(200) NOT NULL COMMENT '영상 제목', + + -- 시간 정보 + uploaded_at DATETIME NOT NULL COMMENT 'SocialUpload 완료 시각 (정렬 기준)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '레코드 생성 시각', + + -- 외래키 제약조건 + CONSTRAINT fk_dashboard_user FOREIGN KEY (user_uuid) REFERENCES user(user_uuid) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='채널별 영상 업로드 기록 테이블 (대시보드 통계 기준)'; + +-- 유니크 인덱스 (동일 채널에 동일 영상 중복 삽입 방지) +CREATE UNIQUE INDEX uq_vcu_video_channel ON dashboard(platform_video_id, platform_user_id); + +-- 복합 인덱스 (사용자별 채널 필터링) +CREATE INDEX idx_vcu_user_platform ON dashboard(user_uuid, platform_user_id); + +-- 인덱스 (날짜 범위 조회) +CREATE INDEX idx_vcu_uploaded_at ON dashboard(uploaded_at); + + +-- =================================================================== +-- 기존 데이터 마이그레이션 +-- social_upload(status=completed) → dashboard INSERT IGNORE +-- 서버 기동 시 init_dashboard_table()에서 자동 실행됩니다. +-- 아래 쿼리는 수동 실행 시 참고용입니다. +-- =================================================================== + +/* +INSERT IGNORE INTO dashboard ( + user_uuid, + platform, + platform_user_id, + platform_video_id, + platform_url, + title, + uploaded_at +) +SELECT + su.user_uuid, + su.platform, + sa.platform_user_id, + su.platform_video_id, + su.platform_url, + su.title, + su.uploaded_at +FROM social_upload su +JOIN social_account sa ON su.social_account_id = sa.id +WHERE + su.status = 'completed' + AND su.platform_video_id IS NOT NULL + AND su.uploaded_at IS NOT NULL + AND sa.platform_user_id IS NOT NULL; +*/ From cb267192d24b6f8225400bc3a090de3bcf2347ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Thu, 26 Feb 2026 09:17:35 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=84=EC=86=A1=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=ED=8F=AC=EB=A9=A7=ED=8C=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/dashboard/api/routers/v1/dashboard.py | 52 +++--- app/dashboard/schemas/__init__.py | 4 - app/dashboard/schemas/dashboard_schema.py | 171 ++++++++------------ app/dashboard/services/data_processor.py | 87 +++------- app/dashboard/services/youtube_analytics.py | 31 ++-- 5 files changed, 137 insertions(+), 208 deletions(-) diff --git a/app/dashboard/api/routers/v1/dashboard.py b/app/dashboard/api/routers/v1/dashboard.py index c38be32..3b5908d 100644 --- a/app/dashboard/api/routers/v1/dashboard.py +++ b/app/dashboard/api/routers/v1/dashboard.py @@ -14,16 +14,17 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.dashboard.exceptions import ( - NoVideosFoundError, YouTubeAccountNotConnectedError, YouTubeAccountNotFoundError, YouTubeAccountSelectionRequiredError, YouTubeTokenExpiredError, ) from app.dashboard.schemas import ( + AudienceData, CacheDeleteResponse, ConnectedAccount, ConnectedAccountsResponse, + ContentMetric, DashboardResponse, TopContent, ) @@ -53,7 +54,7 @@ router = APIRouter(prefix="/dashboard", tags=["Dashboard"]) description=""" 연결된 소셜 계정 목록을 반환합니다. -여러 계정이 연결된 경우, 반환된 `id` 값을 `/dashboard/stats?social_account_id=`에 전달하여 계정을 선택합니다. +여러 계정이 연결된 경우, 반환된 `platformUserId` 값을 `/dashboard/stats?platform_user_id=<값>`에 전달하여 계정을 선택합니다. """, ) async def get_connected_accounts( @@ -107,18 +108,17 @@ YouTube Analytics API를 활용한 대시보드 통계를 조회합니다. ## 주요 기능 - 최근 30개 업로드 영상 기준 통계 제공 -- KPI 지표: 업로드 영상 수, 총 조회수, 시청 시간, 평균 시청 시간, 신규 구독자, 좋아요, 댓글, 공유 +- KPI 지표: 조회수, 시청시간, 평균 시청시간, 신규 구독자, 좋아요, 댓글, 공유, 업로드 영상 - 월별 추이: 최근 12개월 vs 이전 12개월 비교 - 인기 영상 TOP 4 - 시청자 분석: 연령/성별/지역 분포 ## 성능 최적화 -- 8개 YouTube Analytics API를 병렬로 호출 +- 7개 YouTube Analytics API를 병렬로 호출 - Redis 캐싱 적용 (TTL: 12시간) ## 사전 조건 - YouTube 계정이 연동되어 있어야 합니다 -- 최소 1개 이상의 영상이 업로드되어 있어야 합니다 ## 조회 모드 - `day`: 최근 30일 통계 (현재 날짜 -2일 기준) @@ -158,7 +158,6 @@ async def get_dashboard_stats( YouTubeAccountNotConnectedError: YouTube 계정이 연동되어 있지 않음 YouTubeAccountSelectionRequiredError: 여러 계정이 연결되어 있으나 계정 미선택 YouTubeAccountNotFoundError: 지정한 계정을 찾을 수 없음 - NoVideosFoundError: 업로드된 영상이 없음 YouTubeTokenExpiredError: YouTube 토큰 만료 (재연동 필요) YouTubeAPIError: YouTube Analytics API 호출 실패 """ @@ -302,7 +301,7 @@ async def get_dashboard_stats( response = DashboardResponse.model_validate(payload["response"]) for metric in response.content_metrics: if metric.id == "uploaded-videos": - metric.value = str(period_video_count) + 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 "-") @@ -332,14 +331,7 @@ async def get_dashboard_stats( .limit(30) ) rows = result.all() - # 테스트를 위해 주석처리 - # if not rows: - # logger.warning( - # f"[NO VIDEOS] 업로드된 영상 없음 - " f"user_uuid={current_user.user_uuid}" - # ) - # raise NoVideosFoundError() - - # logger.debug(f"[5] 영상 조회 완료 - count={len(rows)}") + logger.debug(f"[5] 영상 조회 완료 - count={len(rows)}") # 6. video_ids + 메타데이터 조회용 dict 구성 video_ids = [] @@ -354,6 +346,30 @@ async def get_dashboard_stats( f"[6] 영상 메타데이터 구성 완료 - count={len(video_ids)}, sample={video_ids[:3]}" ) + # 6.1 업로드 영상 없음 → YouTube API 호출 없이 빈 응답 반환 + if not video_ids: + logger.info( + f"[DASHBOARD] 업로드 영상 없음, 빈 응답 반환 - " + f"user_uuid={current_user.user_uuid}" + ) + 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, + ) + # 7. 토큰 유효성 확인 및 자동 갱신 (만료 10분 전 갱신) try: access_token = await SocialAccountService().ensure_valid_token( @@ -367,7 +383,7 @@ async def get_dashboard_stats( logger.debug("[7] 토큰 유효성 확인 완료") - # 8. YouTube Analytics API 호출 (8개 병렬) + # 8. YouTube Analytics API 호출 (7개 병렬) youtube_service = YouTubeAnalyticsService() raw_data = await youtube_service.fetch_all_metrics( video_ids=video_ids, @@ -399,7 +415,7 @@ async def get_dashboard_stats( title=title, thumbnail=f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg", platform="youtube", - views=DataProcessor._format_number(views), + views=int(views), engagement=f"{engagement_rate:.1f}%", date=uploaded_at.strftime("%Y.%m.%d"), ) @@ -464,7 +480,7 @@ async def get_dashboard_stats( - 데이터 이상 발생 시 캐시 강제 갱신 ## 캐시 키 구조 -`dashboard:{user_uuid}:{mode}` (mode: day 또는 month) +`dashboard:{user_uuid}:{platform_user_id}:{mode}` (mode: day 또는 month) ## 파라미터 - `user_uuid`: 특정 사용자 캐시만 삭제. 미입력 시 전체 삭제 diff --git a/app/dashboard/schemas/__init__.py b/app/dashboard/schemas/__init__.py index 0044ba9..7293d01 100644 --- a/app/dashboard/schemas/__init__.py +++ b/app/dashboard/schemas/__init__.py @@ -13,8 +13,6 @@ from app.dashboard.schemas.dashboard_schema import ( DailyData, DashboardResponse, MonthlyData, - PlatformData, - PlatformMetric, TopContent, ) @@ -26,8 +24,6 @@ __all__ = [ "MonthlyData", "TopContent", "AudienceData", - "PlatformMetric", - "PlatformData", "DashboardResponse", "CacheDeleteResponse", ] diff --git a/app/dashboard/schemas/dashboard_schema.py b/app/dashboard/schemas/dashboard_schema.py index 8aab0d4..cac1c7b 100644 --- a/app/dashboard/schemas/dashboard_schema.py +++ b/app/dashboard/schemas/dashboard_schema.py @@ -22,10 +22,10 @@ def to_camel(string: str) -> str: """snake_case를 camelCase로 변환 Args: - string: snake_case 문자열 (예: "label_en") + string: snake_case 문자열 (예: "content_metrics") Returns: - camelCase 문자열 (예: "labelEn") + camelCase 문자열 (예: "contentMetrics") Example: >>> to_camel("content_metrics") @@ -43,28 +43,31 @@ class ContentMetric(BaseModel): 대시보드 상단에 표시되는 핵심 성과 지표(KPI) 카드입니다. Attributes: - id: 지표 고유 ID (예: "total-views", "total-engagement") - label: 한글 라벨 (예: "총 조회수") - label_en: 영문 라벨 (예: "Total Views") - value: 포맷팅된 값 (예: "1.2M", "8.2%") - trend: 증감 추세 값 (예: 12.5 → 12.5% 증가) - trend_direction: 추세 방향 ("up" 또는 "down") + 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="총 조회수", - ... label_en="Total Views", - ... value="1.2M", - ... trend=12.5, + ... label="조회수", + ... value=1200000.0, + ... unit="count", + ... trend=3800.0, ... trend_direction="up" ... ) """ id: str label: str - label_en: str - value: str + value: float + unit: str = "count" trend: float trend_direction: Literal["up", "down", "-"] = Field(alias="trendDirection") @@ -133,7 +136,7 @@ class TopContent(BaseModel): title: 영상 제목 thumbnail: 썸네일 이미지 URL platform: 플랫폼 ("youtube" 또는 "instagram") - views: 포맷팅된 조회수 (예: "125K") + views: 원시 조회수 정수 (포맷팅은 프론트에서 처리, 예: 125400) engagement: 참여율 (예: "8.2%") date: 업로드 날짜 (예: "2026.01.15") @@ -143,7 +146,7 @@ class TopContent(BaseModel): ... title="힐링 영상", ... thumbnail="https://i.ytimg.com/...", ... platform="youtube", - ... views="125K", + ... views=125400, ... engagement="8.2%", ... date="2026.01.15" ... ) @@ -153,7 +156,7 @@ class TopContent(BaseModel): title: str thumbnail: str platform: Literal["youtube", "instagram"] - views: str + views: int engagement: str date: str @@ -169,18 +172,18 @@ class AudienceData(BaseModel): 시청자의 연령대, 성별, 지역 분포 데이터입니다. Attributes: - age_groups: 연령대별 비율 리스트 + age_groups: 연령대별 시청자 비율 리스트 [{"label": "18-24", "percentage": 35}, ...] - gender: 성별 조회수 - {"male": 45000, "female": 55000} - top_regions: 상위 지역 리스트 - [{"region": "서울", "percentage": 42}, ...] + 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}] + ... top_regions=[{"region": "대한민국", "percentage": 42}] ... ) """ @@ -194,90 +197,47 @@ class AudienceData(BaseModel): ) -class PlatformMetric(BaseModel): - """플랫폼별 메트릭 - - 플랫폼별 세부 지표입니다 (예: 구독자 증가, 구독 취소). - - Attributes: - id: 메트릭 고유 ID (예: "subscribers-gained") - label: 지표명 (예: "구독자 증가") - value: 포맷팅된 값 (예: "1.2K") - unit: 단위 (선택, 예: "명", "%") - trend: 증감 추세 값 - trend_direction: 추세 방향 ("up" 또는 "down") - - Example: - >>> metric = PlatformMetric( - ... id="subscribers-gained", - ... label="구독자 증가", - ... value="1.2K", - ... trend=8.5, - ... trend_direction="up" - ... ) - """ - - id: str - label: str - value: str - unit: Optional[str] = None - trend: float - trend_direction: Literal["up", "down", "-"] = Field(alias="trendDirection") - - model_config = ConfigDict( - alias_generator=to_camel, - populate_by_name=True, - ) - - -class PlatformData(BaseModel): - """플랫폼별 데이터 - - 플랫폼별로 그룹화된 메트릭 데이터입니다. - - Attributes: - platform: 플랫폼 종류 ("youtube" 또는 "instagram") - display_name: 표시할 플랫폼 이름 (예: "YouTube") - metrics: 플랫폼별 메트릭 리스트 - - Example: - >>> data = PlatformData( - ... platform="youtube", - ... display_name="YouTube", - ... metrics=[ - ... PlatformMetric( - ... id="subscribers", - ... label="구독자 증가", - ... value="1.2K", - ... trend=8.5, - ... trend_direction="up" - ... ) - ... ] - ... ) - """ - - platform: Literal["youtube", "instagram"] - display_name: str = Field(alias="displayName") - metrics: list[PlatformMetric] - - model_config = ConfigDict( - alias_generator=to_camel, - populate_by_name=True, - ) +# class PlatformMetric(BaseModel): +# """플랫폼별 메트릭 (미사용 — platform_data 기능 미구현)""" +# +# id: str +# label: str +# value: str +# unit: Optional[str] = None +# trend: float +# trend_direction: Literal["up", "down", "-"] = Field(alias="trendDirection") +# +# model_config = ConfigDict( +# alias_generator=to_camel, +# populate_by_name=True, +# ) +# +# +# class PlatformData(BaseModel): +# """플랫폼별 데이터 (미사용 — platform_data 기능 미구현)""" +# +# platform: Literal["youtube", "instagram"] +# display_name: str = Field(alias="displayName") +# metrics: list[PlatformMetric] +# +# model_config = ConfigDict( +# alias_generator=to_camel, +# populate_by_name=True, +# ) class DashboardResponse(BaseModel): """대시보드 전체 응답 - GET /api/dashboard/stats 엔드포인트의 전체 응답 스키마입니다. - 모든 대시보드 데이터를 포함합니다. + GET /dashboard/stats 엔드포인트의 전체 응답 스키마입니다. Attributes: - content_metrics: KPI 지표 카드 리스트 - monthly_data: 월별 추이 데이터 (전년 대비) - top_content: 인기 영상 리스트 - audience_data: 시청자 분석 데이터 - platform_data: 플랫폼별 메트릭 데이터 + 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( @@ -285,7 +245,6 @@ class DashboardResponse(BaseModel): ... monthly_data=[...], ... top_content=[...], ... audience_data=AudienceData(...), - ... platform_data=[...] ... ) >>> json_str = response.model_dump_json() # JSON 직렬화 """ @@ -295,7 +254,8 @@ class DashboardResponse(BaseModel): daily_data: list[DailyData] = Field(default=[], alias="dailyData") top_content: list[TopContent] = Field(alias="topContent") audience_data: AudienceData = Field(alias="audienceData") - platform_data: list[PlatformData] = Field(default=[], alias="platformData") + has_uploads: bool = Field(default=True, alias="hasUploads") + # platform_data: list[PlatformData] = Field(default=[], alias="platformData") # 미사용 model_config = ConfigDict( alias_generator=to_camel, @@ -307,11 +267,12 @@ class ConnectedAccount(BaseModel): """연결된 소셜 계정 정보 Attributes: - id: SocialAccount 테이블 ID (계정 선택 시 이 값을 social_account_id로 전달) + id: SocialAccount 테이블 PK platform: 플랫폼 (예: "youtube") platform_username: 플랫폼 사용자명 (예: "@channelname") - platform_user_id: 플랫폼 사용자 고유 ID - channel_title: YouTube 채널 제목 (platform_data에서 추출) + platform_user_id: 플랫폼 채널 고유 ID — 재연동해도 불변. + /dashboard/stats?platform_user_id=<값> 으로 계정 선택에 사용 + channel_title: YouTube 채널 제목 (SocialAccount.platform_data JSON에서 추출) connected_at: 연동 일시 is_active: 활성화 상태 """ diff --git a/app/dashboard/services/data_processor.py b/app/dashboard/services/data_processor.py index f808a57..a5c51f1 100644 --- a/app/dashboard/services/data_processor.py +++ b/app/dashboard/services/data_processor.py @@ -204,64 +204,64 @@ class DataProcessor: ContentMetric( id="total-views", label="조회수", - label_en="Total Views", - value=self._format_number(int(views)), + value=float(views), + unit="count", trend=round(float(views_trend), 1), trend_direction=views_dir, ), ContentMetric( id="total-watch-time", label="시청시간", - label_en="Watch Time", - value=f"{round(estimated_minutes_watched / 60, 1)}시간", + 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="평균 시청시간", - label_en="Avg. View Duration", - value=f"{round(average_view_duration / 60)}분", + value=round(average_view_duration / 60, 1), + unit="minutes", trend=round(duration_trend / 60, 1), trend_direction=duration_dir, ), ContentMetric( id="new-subscribers", label="신규 구독자", - label_en="New Subscribers", - value=self._format_number(int(subscribers_gained)), - trend=round(float(subs_trend), 1), + value=float(subscribers_gained), + unit="count", + trend=subs_trend, trend_direction=subs_dir, ), ContentMetric( id="likes", label="좋아요", - label_en="Likes", - value=self._format_number(int(likes)), - trend=round(float(likes_trend), 1), + value=float(likes), + unit="count", + trend=likes_trend, trend_direction=likes_dir, ), ContentMetric( id="comments", label="댓글", - label_en="Comments", - value=self._format_number(int(comments)), - trend=round(float(comments_trend), 1), + value=float(comments), + unit="count", + trend=comments_trend, trend_direction=comments_dir, ), ContentMetric( id="shares", label="공유", - label_en="Shares", - value=self._format_number(int(shares)), - trend=round(float(shares_trend), 1), + value=float(shares), + unit="count", + trend=shares_trend, trend_direction=shares_dir, ), ContentMetric( id="uploaded-videos", label="업로드 영상", - label_en="Uploaded Videos", - value=str(period_video_count), + value=float(period_video_count), + unit="count", trend=0.0, trend_direction="-", ), @@ -475,53 +475,6 @@ class DataProcessor: top_regions=top_regions, ) - @staticmethod - def _format_number(num: int) -> str: - """숫자 포맷팅 (1234567 → "1.2M") - - 조회수, 구독자 수 등 큰 숫자를 읽기 쉽게 포맷팅합니다. - - Args: - num: 원본 숫자 - - Returns: - str: 포맷팅된 문자열 - - 1,000,000 이상: "1.2M" - - 1,000 이상: "12.5K" - - 1,000 미만: "123" - - Example: - >>> _format_number(1234567) - "1.2M" - >>> _format_number(12345) - "12.3K" - >>> _format_number(123) - "123" - """ - if num >= 1_000_000: - return f"{num / 1_000_000:.1f}M" - elif num >= 1_000: - return f"{num / 1_000:.1f}K" - else: - return str(num) - - @staticmethod - def _format_duration(seconds: int) -> str: - """초를 M:SS 형식으로 변환 (평균 시청 시간 표시용) - - Args: - seconds: 초 단위 시간 - - Returns: - str: M:SS 형식 문자열 - - 204초 → "3:24" - - 65초 → "1:05" - - 45초 → "0:45" - """ - minutes = seconds // 60 - secs = seconds % 60 - return f"{minutes}:{secs:02d}" - @staticmethod def _translate_country_code(code: str) -> str: """국가 코드를 한국어로 변환 diff --git a/app/dashboard/services/youtube_analytics.py b/app/dashboard/services/youtube_analytics.py index 9211344..e110ac1 100644 --- a/app/dashboard/services/youtube_analytics.py +++ b/app/dashboard/services/youtube_analytics.py @@ -44,19 +44,22 @@ class YouTubeAnalyticsService: """YouTube Analytics API 호출을 병렬로 실행 Args: - video_ids: YouTube 영상 ID 리스트 (최대 30개) + 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 응답 데이터 - - kpi: KPI 메트릭 (조회수, 좋아요, 댓글 등) - - monthly_recent: 최근 12개월 월별 조회수 - - monthly_previous: 이전 12개월 월별 조회수 - - top_videos: 인기 영상 TOP 4 - - demographics: 연령/성별 분포 - - region: 지역별 조회수 + 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 호출 실패 @@ -73,7 +76,7 @@ class YouTubeAnalyticsService: ... ) """ logger.debug( - f"[1/6] YouTube Analytics API 병렬 호출 시작 - " + f"[1/7] YouTube Analytics API 병렬 호출 시작 - " f"video_count={len(video_ids)}, period={start_date}~{end_date}, mode={mode}" ) @@ -255,7 +258,7 @@ class YouTubeAnalyticsService: "endDate": end_date, "dimensions": "month", "metrics": "views", - # "filters": f"video=={','.join(video_ids)}", + "filters": f"video=={','.join(video_ids)}", "sort": "month", } @@ -299,7 +302,7 @@ class YouTubeAnalyticsService: "endDate": end_date, "dimensions": "day", "metrics": "views", - # "filters": f"video=={','.join(video_ids)}", + "filters": f"video=={','.join(video_ids)}", "sort": "day", } @@ -340,7 +343,7 @@ class YouTubeAnalyticsService: "endDate": end_date, "dimensions": "video", "metrics": "views,likes,comments", - # "filters": f"video=={','.join(video_ids)}", + "filters": f"video=={','.join(video_ids)}", "sort": "-views", "maxResults": "4", } @@ -410,7 +413,7 @@ class YouTubeAnalyticsService: rows = [["KR", 1000000], ["US", 500000], ...] 조회수 내림차순으로 정렬된 상위 5개 국가 """ - logger.debug("[YouTubeAnalyticsService._fetch_country] START") + logger.debug("[YouTubeAnalyticsService._fetch_region] START") params = { "ids": "channel==MINE", @@ -418,7 +421,7 @@ class YouTubeAnalyticsService: "endDate": end_date, "dimensions": "country", "metrics": "views", - # "filters": f"video=={','.join(video_ids)}", + "filters": f"video=={','.join(video_ids)}", "sort": "-views", "maxResults": "5", } From 8fe0512608a38eb2301ebc40be725616e2b4ebcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Fri, 27 Feb 2026 15:26:38 +0900 Subject: [PATCH 4/5] =?UTF-8?q?docs=20YouTube=20Analytics=20API=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=98=A4=EB=94=94=EC=96=B8?= =?UTF-8?q?=EC=8A=A4=20=EC=9D=B8=EC=82=AC=EC=9D=B4=ED=8A=B8=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/dashboard/api/routers/v1/dashboard.py | 2 +- app/dashboard/services/data_processor.py | 26 +++---- app/dashboard/services/youtube_analytics.py | 12 +-- docs/dashboard/YouTube Analytics API.md | 86 +++++++++++++++++---- 4 files changed, 84 insertions(+), 42 deletions(-) diff --git a/app/dashboard/api/routers/v1/dashboard.py b/app/dashboard/api/routers/v1/dashboard.py index 3b5908d..4648d4d 100644 --- a/app/dashboard/api/routers/v1/dashboard.py +++ b/app/dashboard/api/routers/v1/dashboard.py @@ -267,7 +267,7 @@ async def get_dashboard_stats( Dashboard.platform == "youtube", Dashboard.platform_user_id == social_account.platform_user_id, Dashboard.uploaded_at >= start_dt, - Dashboard.uploaded_at <= kpi_end_dt, + Dashboard.uploaded_at <= end_dt, ) ) period_video_count = count_result.scalar() or 0 diff --git a/app/dashboard/services/data_processor.py b/app/dashboard/services/data_processor.py index a5c51f1..8214e37 100644 --- a/app/dashboard/services/data_processor.py +++ b/app/dashboard/services/data_processor.py @@ -418,8 +418,8 @@ class DataProcessor: # === 연령/성별 데이터 처리 === demo_rows = demographics_data.get("rows", []) - age_map: dict[str, int] = {} - gender_map = {"male": 0, "female": 0} + 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: @@ -427,27 +427,21 @@ class DataProcessor: age_group = row[0] # "age18-24" gender = row[1] # "male" or "female" - views = row[2] + viewer_pct = row[2] # viewerPercentage (이미 % 값, 예: 45.5) - # 연령대별 집계 (age18-24 → 18-24) + # 연령대별 집계: 남녀 비율 합산 (age18-24 → 18-24) age_label = age_group.replace("age", "") - age_map[age_label] = age_map.get(age_label, 0) + views + age_map[age_label] = age_map.get(age_label, 0.0) + viewer_pct # 성별 집계 - if gender in gender_map: - gender_map[gender] += views + if gender in gender_map_f: + gender_map_f[gender] += viewer_pct - # 연령대별 비율 계산 - total_demo_views = sum(age_map.values()) age_groups = [ - { - "label": age, - "percentage": int( - (count / total_demo_views * 100) if total_demo_views > 0 else 0 - ), - } - for age, count in sorted(age_map.items()) + {"label": age, "percentage": int(round(pct))} + for age, pct in sorted(age_map.items()) ] + gender_map = {k: int(round(v)) for k, v in gender_map_f.items()} # === 지역 데이터 처리 === geo_rows = geography_data.get("rows", []) diff --git a/app/dashboard/services/youtube_analytics.py b/app/dashboard/services/youtube_analytics.py index e110ac1..bb3bbf0 100644 --- a/app/dashboard/services/youtube_analytics.py +++ b/app/dashboard/services/youtube_analytics.py @@ -124,7 +124,7 @@ class YouTubeAnalyticsService: 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(video_ids, start_date, _kpi_end, access_token), + self._fetch_region(start_date, _kpi_end, access_token), ] else: # mode == "day" tasks = [ @@ -134,7 +134,7 @@ class YouTubeAnalyticsService: 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(video_ids, start_date, end_date, access_token), + self._fetch_region(start_date, end_date, access_token), ] # 병렬 실행 @@ -206,8 +206,6 @@ class YouTubeAnalyticsService: estimatedMinutesWatched, averageViewDuration, subscribersGained] - Note: - annotationClickThroughRate는 2019년 annotations 기능 제거로 deprecated. """ logger.debug( f"[YouTubeAnalyticsService._fetch_kpi] START - video_count={len(video_ids)}" @@ -218,7 +216,7 @@ class YouTubeAnalyticsService: "startDate": start_date, "endDate": end_date, "metrics": "views,likes,comments,shares,estimatedMinutesWatched,averageViewDuration,subscribersGained", - # "filters": f"video=={','.join(video_ids)}", + "filters": f"video=={','.join(video_ids)}", } result = await self._call_api(params, access_token) @@ -393,7 +391,6 @@ class YouTubeAnalyticsService: async def _fetch_region( self, - video_ids: list[str], start_date: str, end_date: str, access_token: str, @@ -403,7 +400,6 @@ class YouTubeAnalyticsService: 지역별 조회수 분포를 조회합니다 (상위 5개). Args: - video_ids: YouTube 영상 ID 리스트 start_date: 조회 시작일 (YYYY-MM-DD) end_date: 조회 종료일 (YYYY-MM-DD) access_token: OAuth 2.0 액세스 토큰 @@ -421,9 +417,7 @@ class YouTubeAnalyticsService: "endDate": end_date, "dimensions": "country", "metrics": "views", - "filters": f"video=={','.join(video_ids)}", "sort": "-views", - "maxResults": "5", } result = await self._call_api(params, access_token) diff --git a/docs/dashboard/YouTube Analytics API.md b/docs/dashboard/YouTube Analytics API.md index e52e37f..fd934be 100644 --- a/docs/dashboard/YouTube Analytics API.md +++ b/docs/dashboard/YouTube Analytics API.md @@ -70,7 +70,7 @@ API 요청 시 `dimensions` 파라미터에 들어갈 수 있는 값들입니다 | -------------- | ------- | --------------------------- | -------------- | | `**ageGroup`** | **연령대** | 시청자 연령 분포 (18-24, 25-34...) | `video`와 혼용 불가 | | `**gender`** | **성별** | 남녀 성비 (male, female) | `video`와 혼용 불가 | -| `**country`** | **국가** | 국가별 시청자 수 (KR, US...) | 지도 차트용 | +| `**country`** | **국가** | 국가별 시청자 수 (KR, US...) | 지도 차트용, 채널 전체 기준 | ### C. 유입 및 기기 (Traffic Device) @@ -84,22 +84,76 @@ API 요청 시 `dimensions` 파라미터에 들어갈 수 있는 값들입니다 --- -## 3. 자주 쓰는 조합 (Best Practice) +## 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` | -1. **프로젝트 전체 요약 (KPI)** - - `dimensions`: (없음) - - `metrics`: `views,likes,estimatedRevenue` - - `filters`: `video==ID1,ID2...` -2. **일별 성장 그래프 (Line Chart)** - - `dimensions`: `day` - - `metrics`: `views` - - `filters`: `video==ID1,ID2...` - - `sort`: `day` -3. **인기 영상 랭킹 (Table)** - - `dimensions`: `video` - - `metrics`: `views,averageViewDuration` - - `filters`: `video==ID1,ID2...` - - `sort`: `-views` --- From c705ce40f8651284feae56b868fc8ca142df3b52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 3 Mar 2026 15:23:31 +0900 Subject: [PATCH 5/5] =?UTF-8?q?audience=5Fdata=20=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B8=B0=ED=83=80=20=EC=B2=98=EB=A6=AC=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/dashboard/api/routers/v1/dashboard.py | 4 +- app/dashboard/services/data_processor.py | 126 +++++++++++++--------- 2 files changed, 80 insertions(+), 50 deletions(-) diff --git a/app/dashboard/api/routers/v1/dashboard.py b/app/dashboard/api/routers/v1/dashboard.py index 4648d4d..fa4b860 100644 --- a/app/dashboard/api/routers/v1/dashboard.py +++ b/app/dashboard/api/routers/v1/dashboard.py @@ -267,7 +267,7 @@ async def get_dashboard_stats( Dashboard.platform == "youtube", Dashboard.platform_user_id == social_account.platform_user_id, Dashboard.uploaded_at >= start_dt, - Dashboard.uploaded_at <= end_dt, + Dashboard.uploaded_at < today + timedelta(days=1), ) ) period_video_count = count_result.scalar() or 0 @@ -451,7 +451,7 @@ async def get_dashboard_stats( # 12. 업로드 영상 수 및 trend 주입 (캐시 저장 후 — 항상 DB에서 직접 집계) for metric in dashboard_data.content_metrics: if metric.id == "uploaded-videos": - metric.value = str(period_video_count) + 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 "-") diff --git a/app/dashboard/services/data_processor.py b/app/dashboard/services/data_processor.py index 8214e37..a396aab 100644 --- a/app/dashboard/services/data_processor.py +++ b/app/dashboard/services/data_processor.py @@ -4,6 +4,7 @@ YouTube Analytics 데이터 가공 프로세서 YouTube Analytics API의 원본 데이터를 프론트엔드용 Pydantic 스키마로 변환합니다. """ +from collections import defaultdict from datetime import datetime, timedelta from typing import Any, Literal @@ -19,6 +20,46 @@ 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 데이터 가공 프로세서 @@ -90,6 +131,7 @@ class DataProcessor: 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" @@ -271,49 +313,49 @@ class DataProcessor: self, data_recent: dict[str, Any], data_previous: dict[str, Any], + end_date: str = "", ) -> list[MonthlyData]: """최근 12개월과 이전 12개월의 월별 데이터를 병합 - 최근 12개월 대비 이전 12개월의 월별 조회수 비교 차트를 위한 데이터를 생성합니다. - 실제 API 응답의 월 데이터를 기준으로 매핑합니다. + 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개) + list[MonthlyData]: 월별 비교 데이터 (12개, API 미반환 월은 0) """ logger.debug("[DataProcessor._merge_monthly_data] START") rows_recent = data_recent.get("rows", []) rows_previous = data_previous.get("rows", []) - # 월별 맵 생성: {"2025-02": 150000, "2025-03": 180000} 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} - # 최근 기간의 월 키만 기준으로 정렬 (24개 합집합 방지) - # 각 월의 이전 연도 키는 1년 전으로 계산: "2025-02" → "2024-02" - recent_months = sorted(map_recent.keys()) + # end_date 기준 12개월 명시 생성 (API 미반환 당월도 0으로 포함) + if end_date: + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + else: + end_dt = datetime.today() - # 월별 데이터 생성 result = [] - for month_key in recent_months: - year, month = month_key.split("-") - month_num = int(month) - month_label = f"{month_num}월" - - # 이전 연도 동일 월: "2025-02" → "2024-02" - prev_year_key = f"{int(year) - 1}-{month}" - + 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=month_label, + month=f"{m}월", this_year=map_recent.get(month_key, 0), - last_year=map_previous.get(prev_year_key, 0), + last_year=map_previous.get(f"{y - 1}-{m:02d}", 0), ) ) @@ -437,9 +479,17 @@ class DataProcessor: 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 sorted(age_map.items()) + for age, pct in merged_age.items() ] gender_map = {k: int(round(v)) for k, v in gender_map_f.items()} @@ -447,15 +497,17 @@ class DataProcessor: 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": self._translate_country_code(row[0]), - "percentage": int( - (row[1] / total_geo_views * 100) if total_geo_views > 0 else 0 - ), + "region": region, + "percentage": int((views / total_geo_views * 100) if total_geo_views > 0 else 0), } - for row in geo_rows[:5] # 상위 5개 - if len(row) >= 2 + for region, views in sorted(merged_geo.items(), key=lambda x: x[1], reverse=True)[:5] ] logger.debug( @@ -487,26 +539,4 @@ class DataProcessor: >>> _translate_country_code("US") "미국" """ - country_map = { - "KR": "대한민국", - "US": "미국", - "JP": "일본", - "CN": "중국", - "GB": "영국", - "DE": "독일", - "FR": "프랑스", - "CA": "캐나다", - "AU": "호주", - "IN": "인도", - "ID": "인도네시아", - "TH": "태국", - "VN": "베트남", - "PH": "필리핀", - "MY": "말레이시아", - "SG": "싱가포르", - "TW": "대만", - "HK": "홍콩", - "BR": "브라질", - "MX": "멕시코", - } - return country_map.get(code, code) + return _COUNTRY_CODE_MAP.get(code, "기타")