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; +*/