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, "기타")