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", }