사용하지 않는 코드 정리 및 전송 데이터 포멧팅 변경
parent
a0c352f567
commit
cb267192d2
|
|
@ -14,16 +14,17 @@ from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.dashboard.exceptions import (
|
from app.dashboard.exceptions import (
|
||||||
NoVideosFoundError,
|
|
||||||
YouTubeAccountNotConnectedError,
|
YouTubeAccountNotConnectedError,
|
||||||
YouTubeAccountNotFoundError,
|
YouTubeAccountNotFoundError,
|
||||||
YouTubeAccountSelectionRequiredError,
|
YouTubeAccountSelectionRequiredError,
|
||||||
YouTubeTokenExpiredError,
|
YouTubeTokenExpiredError,
|
||||||
)
|
)
|
||||||
from app.dashboard.schemas import (
|
from app.dashboard.schemas import (
|
||||||
|
AudienceData,
|
||||||
CacheDeleteResponse,
|
CacheDeleteResponse,
|
||||||
ConnectedAccount,
|
ConnectedAccount,
|
||||||
ConnectedAccountsResponse,
|
ConnectedAccountsResponse,
|
||||||
|
ContentMetric,
|
||||||
DashboardResponse,
|
DashboardResponse,
|
||||||
TopContent,
|
TopContent,
|
||||||
)
|
)
|
||||||
|
|
@ -53,7 +54,7 @@ router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
|
||||||
description="""
|
description="""
|
||||||
연결된 소셜 계정 목록을 반환합니다.
|
연결된 소셜 계정 목록을 반환합니다.
|
||||||
|
|
||||||
여러 계정이 연결된 경우, 반환된 `id` 값을 `/dashboard/stats?social_account_id=<id>`에 전달하여 계정을 선택합니다.
|
여러 계정이 연결된 경우, 반환된 `platformUserId` 값을 `/dashboard/stats?platform_user_id=<값>`에 전달하여 계정을 선택합니다.
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
async def get_connected_accounts(
|
async def get_connected_accounts(
|
||||||
|
|
@ -107,18 +108,17 @@ YouTube Analytics API를 활용한 대시보드 통계를 조회합니다.
|
||||||
|
|
||||||
## 주요 기능
|
## 주요 기능
|
||||||
- 최근 30개 업로드 영상 기준 통계 제공
|
- 최근 30개 업로드 영상 기준 통계 제공
|
||||||
- KPI 지표: 업로드 영상 수, 총 조회수, 시청 시간, 평균 시청 시간, 신규 구독자, 좋아요, 댓글, 공유
|
- KPI 지표: 조회수, 시청시간, 평균 시청시간, 신규 구독자, 좋아요, 댓글, 공유, 업로드 영상
|
||||||
- 월별 추이: 최근 12개월 vs 이전 12개월 비교
|
- 월별 추이: 최근 12개월 vs 이전 12개월 비교
|
||||||
- 인기 영상 TOP 4
|
- 인기 영상 TOP 4
|
||||||
- 시청자 분석: 연령/성별/지역 분포
|
- 시청자 분석: 연령/성별/지역 분포
|
||||||
|
|
||||||
## 성능 최적화
|
## 성능 최적화
|
||||||
- 8개 YouTube Analytics API를 병렬로 호출
|
- 7개 YouTube Analytics API를 병렬로 호출
|
||||||
- Redis 캐싱 적용 (TTL: 12시간)
|
- Redis 캐싱 적용 (TTL: 12시간)
|
||||||
|
|
||||||
## 사전 조건
|
## 사전 조건
|
||||||
- YouTube 계정이 연동되어 있어야 합니다
|
- YouTube 계정이 연동되어 있어야 합니다
|
||||||
- 최소 1개 이상의 영상이 업로드되어 있어야 합니다
|
|
||||||
|
|
||||||
## 조회 모드
|
## 조회 모드
|
||||||
- `day`: 최근 30일 통계 (현재 날짜 -2일 기준)
|
- `day`: 최근 30일 통계 (현재 날짜 -2일 기준)
|
||||||
|
|
@ -158,7 +158,6 @@ async def get_dashboard_stats(
|
||||||
YouTubeAccountNotConnectedError: YouTube 계정이 연동되어 있지 않음
|
YouTubeAccountNotConnectedError: YouTube 계정이 연동되어 있지 않음
|
||||||
YouTubeAccountSelectionRequiredError: 여러 계정이 연결되어 있으나 계정 미선택
|
YouTubeAccountSelectionRequiredError: 여러 계정이 연결되어 있으나 계정 미선택
|
||||||
YouTubeAccountNotFoundError: 지정한 계정을 찾을 수 없음
|
YouTubeAccountNotFoundError: 지정한 계정을 찾을 수 없음
|
||||||
NoVideosFoundError: 업로드된 영상이 없음
|
|
||||||
YouTubeTokenExpiredError: YouTube 토큰 만료 (재연동 필요)
|
YouTubeTokenExpiredError: YouTube 토큰 만료 (재연동 필요)
|
||||||
YouTubeAPIError: YouTube Analytics API 호출 실패
|
YouTubeAPIError: YouTube Analytics API 호출 실패
|
||||||
"""
|
"""
|
||||||
|
|
@ -302,7 +301,7 @@ async def get_dashboard_stats(
|
||||||
response = DashboardResponse.model_validate(payload["response"])
|
response = DashboardResponse.model_validate(payload["response"])
|
||||||
for metric in response.content_metrics:
|
for metric in response.content_metrics:
|
||||||
if metric.id == "uploaded-videos":
|
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)
|
video_trend = float(period_video_count - prev_period_video_count)
|
||||||
metric.trend = video_trend
|
metric.trend = video_trend
|
||||||
metric.trend_direction = "up" if video_trend > 0 else ("down" if video_trend < 0 else "-")
|
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)
|
.limit(30)
|
||||||
)
|
)
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
# 테스트를 위해 주석처리
|
logger.debug(f"[5] 영상 조회 완료 - count={len(rows)}")
|
||||||
# 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 구성
|
# 6. video_ids + 메타데이터 조회용 dict 구성
|
||||||
video_ids = []
|
video_ids = []
|
||||||
|
|
@ -354,6 +346,30 @@ async def get_dashboard_stats(
|
||||||
f"[6] 영상 메타데이터 구성 완료 - count={len(video_ids)}, sample={video_ids[:3]}"
|
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분 전 갱신)
|
# 7. 토큰 유효성 확인 및 자동 갱신 (만료 10분 전 갱신)
|
||||||
try:
|
try:
|
||||||
access_token = await SocialAccountService().ensure_valid_token(
|
access_token = await SocialAccountService().ensure_valid_token(
|
||||||
|
|
@ -367,7 +383,7 @@ async def get_dashboard_stats(
|
||||||
|
|
||||||
logger.debug("[7] 토큰 유효성 확인 완료")
|
logger.debug("[7] 토큰 유효성 확인 완료")
|
||||||
|
|
||||||
# 8. YouTube Analytics API 호출 (8개 병렬)
|
# 8. YouTube Analytics API 호출 (7개 병렬)
|
||||||
youtube_service = YouTubeAnalyticsService()
|
youtube_service = YouTubeAnalyticsService()
|
||||||
raw_data = await youtube_service.fetch_all_metrics(
|
raw_data = await youtube_service.fetch_all_metrics(
|
||||||
video_ids=video_ids,
|
video_ids=video_ids,
|
||||||
|
|
@ -399,7 +415,7 @@ async def get_dashboard_stats(
|
||||||
title=title,
|
title=title,
|
||||||
thumbnail=f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg",
|
thumbnail=f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg",
|
||||||
platform="youtube",
|
platform="youtube",
|
||||||
views=DataProcessor._format_number(views),
|
views=int(views),
|
||||||
engagement=f"{engagement_rate:.1f}%",
|
engagement=f"{engagement_rate:.1f}%",
|
||||||
date=uploaded_at.strftime("%Y.%m.%d"),
|
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`: 특정 사용자 캐시만 삭제. 미입력 시 전체 삭제
|
- `user_uuid`: 특정 사용자 캐시만 삭제. 미입력 시 전체 삭제
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,6 @@ from app.dashboard.schemas.dashboard_schema import (
|
||||||
DailyData,
|
DailyData,
|
||||||
DashboardResponse,
|
DashboardResponse,
|
||||||
MonthlyData,
|
MonthlyData,
|
||||||
PlatformData,
|
|
||||||
PlatformMetric,
|
|
||||||
TopContent,
|
TopContent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -26,8 +24,6 @@ __all__ = [
|
||||||
"MonthlyData",
|
"MonthlyData",
|
||||||
"TopContent",
|
"TopContent",
|
||||||
"AudienceData",
|
"AudienceData",
|
||||||
"PlatformMetric",
|
|
||||||
"PlatformData",
|
|
||||||
"DashboardResponse",
|
"DashboardResponse",
|
||||||
"CacheDeleteResponse",
|
"CacheDeleteResponse",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,10 @@ def to_camel(string: str) -> str:
|
||||||
"""snake_case를 camelCase로 변환
|
"""snake_case를 camelCase로 변환
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
string: snake_case 문자열 (예: "label_en")
|
string: snake_case 문자열 (예: "content_metrics")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
camelCase 문자열 (예: "labelEn")
|
camelCase 문자열 (예: "contentMetrics")
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> to_camel("content_metrics")
|
>>> to_camel("content_metrics")
|
||||||
|
|
@ -43,28 +43,31 @@ class ContentMetric(BaseModel):
|
||||||
대시보드 상단에 표시되는 핵심 성과 지표(KPI) 카드입니다.
|
대시보드 상단에 표시되는 핵심 성과 지표(KPI) 카드입니다.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
id: 지표 고유 ID (예: "total-views", "total-engagement")
|
id: 지표 고유 ID (예: "total-views", "total-watch-time", "new-subscribers")
|
||||||
label: 한글 라벨 (예: "총 조회수")
|
label: 한글 라벨 (예: "조회수")
|
||||||
label_en: 영문 라벨 (예: "Total Views")
|
value: 원시 숫자값 (단위: unit 참조, 포맷팅은 프론트에서 처리)
|
||||||
value: 포맷팅된 값 (예: "1.2M", "8.2%")
|
unit: 값의 단위 — "count" | "hours" | "minutes"
|
||||||
trend: 증감 추세 값 (예: 12.5 → 12.5% 증가)
|
- count: 조회수, 구독자, 좋아요, 댓글, 공유, 업로드 영상
|
||||||
trend_direction: 추세 방향 ("up" 또는 "down")
|
- hours: 시청시간 (estimatedMinutesWatched / 60)
|
||||||
|
- minutes: 평균 시청시간 (averageViewDuration / 60)
|
||||||
|
trend: 이전 기간 대비 증감량 (unit과 동일한 단위)
|
||||||
|
trend_direction: 증감 방향 ("up": 증가, "down": 감소, "-": 변동 없음)
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> metric = ContentMetric(
|
>>> metric = ContentMetric(
|
||||||
... id="total-views",
|
... id="total-views",
|
||||||
... label="총 조회수",
|
... label="조회수",
|
||||||
... label_en="Total Views",
|
... value=1200000.0,
|
||||||
... value="1.2M",
|
... unit="count",
|
||||||
... trend=12.5,
|
... trend=3800.0,
|
||||||
... trend_direction="up"
|
... trend_direction="up"
|
||||||
... )
|
... )
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
label: str
|
label: str
|
||||||
label_en: str
|
value: float
|
||||||
value: str
|
unit: str = "count"
|
||||||
trend: float
|
trend: float
|
||||||
trend_direction: Literal["up", "down", "-"] = Field(alias="trendDirection")
|
trend_direction: Literal["up", "down", "-"] = Field(alias="trendDirection")
|
||||||
|
|
||||||
|
|
@ -133,7 +136,7 @@ class TopContent(BaseModel):
|
||||||
title: 영상 제목
|
title: 영상 제목
|
||||||
thumbnail: 썸네일 이미지 URL
|
thumbnail: 썸네일 이미지 URL
|
||||||
platform: 플랫폼 ("youtube" 또는 "instagram")
|
platform: 플랫폼 ("youtube" 또는 "instagram")
|
||||||
views: 포맷팅된 조회수 (예: "125K")
|
views: 원시 조회수 정수 (포맷팅은 프론트에서 처리, 예: 125400)
|
||||||
engagement: 참여율 (예: "8.2%")
|
engagement: 참여율 (예: "8.2%")
|
||||||
date: 업로드 날짜 (예: "2026.01.15")
|
date: 업로드 날짜 (예: "2026.01.15")
|
||||||
|
|
||||||
|
|
@ -143,7 +146,7 @@ class TopContent(BaseModel):
|
||||||
... title="힐링 영상",
|
... title="힐링 영상",
|
||||||
... thumbnail="https://i.ytimg.com/...",
|
... thumbnail="https://i.ytimg.com/...",
|
||||||
... platform="youtube",
|
... platform="youtube",
|
||||||
... views="125K",
|
... views=125400,
|
||||||
... engagement="8.2%",
|
... engagement="8.2%",
|
||||||
... date="2026.01.15"
|
... date="2026.01.15"
|
||||||
... )
|
... )
|
||||||
|
|
@ -153,7 +156,7 @@ class TopContent(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
thumbnail: str
|
thumbnail: str
|
||||||
platform: Literal["youtube", "instagram"]
|
platform: Literal["youtube", "instagram"]
|
||||||
views: str
|
views: int
|
||||||
engagement: str
|
engagement: str
|
||||||
date: str
|
date: str
|
||||||
|
|
||||||
|
|
@ -169,18 +172,18 @@ class AudienceData(BaseModel):
|
||||||
시청자의 연령대, 성별, 지역 분포 데이터입니다.
|
시청자의 연령대, 성별, 지역 분포 데이터입니다.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
age_groups: 연령대별 비율 리스트
|
age_groups: 연령대별 시청자 비율 리스트
|
||||||
[{"label": "18-24", "percentage": 35}, ...]
|
[{"label": "18-24", "percentage": 35}, ...]
|
||||||
gender: 성별 조회수
|
gender: 성별 시청자 비율 (YouTube viewerPercentage 누적값)
|
||||||
{"male": 45000, "female": 55000}
|
{"male": 45, "female": 55}
|
||||||
top_regions: 상위 지역 리스트
|
top_regions: 상위 국가 리스트 (최대 5개)
|
||||||
[{"region": "서울", "percentage": 42}, ...]
|
[{"region": "대한민국", "percentage": 42}, ...]
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> data = AudienceData(
|
>>> data = AudienceData(
|
||||||
... age_groups=[{"label": "18-24", "percentage": 35}],
|
... age_groups=[{"label": "18-24", "percentage": 35}],
|
||||||
... gender={"male": 45, "female": 55},
|
... 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):
|
# class PlatformMetric(BaseModel):
|
||||||
"""플랫폼별 메트릭
|
# """플랫폼별 메트릭 (미사용 — platform_data 기능 미구현)"""
|
||||||
|
#
|
||||||
플랫폼별 세부 지표입니다 (예: 구독자 증가, 구독 취소).
|
# id: str
|
||||||
|
# label: str
|
||||||
Attributes:
|
# value: str
|
||||||
id: 메트릭 고유 ID (예: "subscribers-gained")
|
# unit: Optional[str] = None
|
||||||
label: 지표명 (예: "구독자 증가")
|
# trend: float
|
||||||
value: 포맷팅된 값 (예: "1.2K")
|
# trend_direction: Literal["up", "down", "-"] = Field(alias="trendDirection")
|
||||||
unit: 단위 (선택, 예: "명", "%")
|
#
|
||||||
trend: 증감 추세 값
|
# model_config = ConfigDict(
|
||||||
trend_direction: 추세 방향 ("up" 또는 "down")
|
# alias_generator=to_camel,
|
||||||
|
# populate_by_name=True,
|
||||||
Example:
|
# )
|
||||||
>>> metric = PlatformMetric(
|
#
|
||||||
... id="subscribers-gained",
|
#
|
||||||
... label="구독자 증가",
|
# class PlatformData(BaseModel):
|
||||||
... value="1.2K",
|
# """플랫폼별 데이터 (미사용 — platform_data 기능 미구현)"""
|
||||||
... trend=8.5,
|
#
|
||||||
... trend_direction="up"
|
# platform: Literal["youtube", "instagram"]
|
||||||
... )
|
# display_name: str = Field(alias="displayName")
|
||||||
"""
|
# metrics: list[PlatformMetric]
|
||||||
|
#
|
||||||
id: str
|
# model_config = ConfigDict(
|
||||||
label: str
|
# alias_generator=to_camel,
|
||||||
value: str
|
# populate_by_name=True,
|
||||||
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):
|
class DashboardResponse(BaseModel):
|
||||||
"""대시보드 전체 응답
|
"""대시보드 전체 응답
|
||||||
|
|
||||||
GET /api/dashboard/stats 엔드포인트의 전체 응답 스키마입니다.
|
GET /dashboard/stats 엔드포인트의 전체 응답 스키마입니다.
|
||||||
모든 대시보드 데이터를 포함합니다.
|
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
content_metrics: KPI 지표 카드 리스트
|
content_metrics: KPI 지표 카드 리스트 (8개)
|
||||||
monthly_data: 월별 추이 데이터 (전년 대비)
|
monthly_data: 월별 추이 데이터 (mode=month 시 채움, 최근 12개월 vs 이전 12개월)
|
||||||
top_content: 인기 영상 리스트
|
daily_data: 일별 추이 데이터 (mode=day 시 채움, 최근 30일 vs 이전 30일)
|
||||||
audience_data: 시청자 분석 데이터
|
top_content: 조회수 기준 인기 영상 TOP 4
|
||||||
platform_data: 플랫폼별 메트릭 데이터
|
audience_data: 시청자 분석 데이터 (연령/성별/지역)
|
||||||
|
has_uploads: 업로드 영상 존재 여부 (False 시 모든 지표가 0, 빈 상태 UI 표시용)
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
>>> response = DashboardResponse(
|
>>> response = DashboardResponse(
|
||||||
|
|
@ -285,7 +245,6 @@ class DashboardResponse(BaseModel):
|
||||||
... monthly_data=[...],
|
... monthly_data=[...],
|
||||||
... top_content=[...],
|
... top_content=[...],
|
||||||
... audience_data=AudienceData(...),
|
... audience_data=AudienceData(...),
|
||||||
... platform_data=[...]
|
|
||||||
... )
|
... )
|
||||||
>>> json_str = response.model_dump_json() # JSON 직렬화
|
>>> json_str = response.model_dump_json() # JSON 직렬화
|
||||||
"""
|
"""
|
||||||
|
|
@ -295,7 +254,8 @@ class DashboardResponse(BaseModel):
|
||||||
daily_data: list[DailyData] = Field(default=[], alias="dailyData")
|
daily_data: list[DailyData] = Field(default=[], alias="dailyData")
|
||||||
top_content: list[TopContent] = Field(alias="topContent")
|
top_content: list[TopContent] = Field(alias="topContent")
|
||||||
audience_data: AudienceData = Field(alias="audienceData")
|
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(
|
model_config = ConfigDict(
|
||||||
alias_generator=to_camel,
|
alias_generator=to_camel,
|
||||||
|
|
@ -307,11 +267,12 @@ class ConnectedAccount(BaseModel):
|
||||||
"""연결된 소셜 계정 정보
|
"""연결된 소셜 계정 정보
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
id: SocialAccount 테이블 ID (계정 선택 시 이 값을 social_account_id로 전달)
|
id: SocialAccount 테이블 PK
|
||||||
platform: 플랫폼 (예: "youtube")
|
platform: 플랫폼 (예: "youtube")
|
||||||
platform_username: 플랫폼 사용자명 (예: "@channelname")
|
platform_username: 플랫폼 사용자명 (예: "@channelname")
|
||||||
platform_user_id: 플랫폼 사용자 고유 ID
|
platform_user_id: 플랫폼 채널 고유 ID — 재연동해도 불변.
|
||||||
channel_title: YouTube 채널 제목 (platform_data에서 추출)
|
/dashboard/stats?platform_user_id=<값> 으로 계정 선택에 사용
|
||||||
|
channel_title: YouTube 채널 제목 (SocialAccount.platform_data JSON에서 추출)
|
||||||
connected_at: 연동 일시
|
connected_at: 연동 일시
|
||||||
is_active: 활성화 상태
|
is_active: 활성화 상태
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -204,64 +204,64 @@ class DataProcessor:
|
||||||
ContentMetric(
|
ContentMetric(
|
||||||
id="total-views",
|
id="total-views",
|
||||||
label="조회수",
|
label="조회수",
|
||||||
label_en="Total Views",
|
value=float(views),
|
||||||
value=self._format_number(int(views)),
|
unit="count",
|
||||||
trend=round(float(views_trend), 1),
|
trend=round(float(views_trend), 1),
|
||||||
trend_direction=views_dir,
|
trend_direction=views_dir,
|
||||||
),
|
),
|
||||||
ContentMetric(
|
ContentMetric(
|
||||||
id="total-watch-time",
|
id="total-watch-time",
|
||||||
label="시청시간",
|
label="시청시간",
|
||||||
label_en="Watch Time",
|
value=round(estimated_minutes_watched / 60, 1),
|
||||||
value=f"{round(estimated_minutes_watched / 60, 1)}시간",
|
unit="hours",
|
||||||
trend=round(watch_trend / 60, 1),
|
trend=round(watch_trend / 60, 1),
|
||||||
trend_direction=watch_dir,
|
trend_direction=watch_dir,
|
||||||
),
|
),
|
||||||
ContentMetric(
|
ContentMetric(
|
||||||
id="avg-view-duration",
|
id="avg-view-duration",
|
||||||
label="평균 시청시간",
|
label="평균 시청시간",
|
||||||
label_en="Avg. View Duration",
|
value=round(average_view_duration / 60, 1),
|
||||||
value=f"{round(average_view_duration / 60)}분",
|
unit="minutes",
|
||||||
trend=round(duration_trend / 60, 1),
|
trend=round(duration_trend / 60, 1),
|
||||||
trend_direction=duration_dir,
|
trend_direction=duration_dir,
|
||||||
),
|
),
|
||||||
ContentMetric(
|
ContentMetric(
|
||||||
id="new-subscribers",
|
id="new-subscribers",
|
||||||
label="신규 구독자",
|
label="신규 구독자",
|
||||||
label_en="New Subscribers",
|
value=float(subscribers_gained),
|
||||||
value=self._format_number(int(subscribers_gained)),
|
unit="count",
|
||||||
trend=round(float(subs_trend), 1),
|
trend=subs_trend,
|
||||||
trend_direction=subs_dir,
|
trend_direction=subs_dir,
|
||||||
),
|
),
|
||||||
ContentMetric(
|
ContentMetric(
|
||||||
id="likes",
|
id="likes",
|
||||||
label="좋아요",
|
label="좋아요",
|
||||||
label_en="Likes",
|
value=float(likes),
|
||||||
value=self._format_number(int(likes)),
|
unit="count",
|
||||||
trend=round(float(likes_trend), 1),
|
trend=likes_trend,
|
||||||
trend_direction=likes_dir,
|
trend_direction=likes_dir,
|
||||||
),
|
),
|
||||||
ContentMetric(
|
ContentMetric(
|
||||||
id="comments",
|
id="comments",
|
||||||
label="댓글",
|
label="댓글",
|
||||||
label_en="Comments",
|
value=float(comments),
|
||||||
value=self._format_number(int(comments)),
|
unit="count",
|
||||||
trend=round(float(comments_trend), 1),
|
trend=comments_trend,
|
||||||
trend_direction=comments_dir,
|
trend_direction=comments_dir,
|
||||||
),
|
),
|
||||||
ContentMetric(
|
ContentMetric(
|
||||||
id="shares",
|
id="shares",
|
||||||
label="공유",
|
label="공유",
|
||||||
label_en="Shares",
|
value=float(shares),
|
||||||
value=self._format_number(int(shares)),
|
unit="count",
|
||||||
trend=round(float(shares_trend), 1),
|
trend=shares_trend,
|
||||||
trend_direction=shares_dir,
|
trend_direction=shares_dir,
|
||||||
),
|
),
|
||||||
ContentMetric(
|
ContentMetric(
|
||||||
id="uploaded-videos",
|
id="uploaded-videos",
|
||||||
label="업로드 영상",
|
label="업로드 영상",
|
||||||
label_en="Uploaded Videos",
|
value=float(period_video_count),
|
||||||
value=str(period_video_count),
|
unit="count",
|
||||||
trend=0.0,
|
trend=0.0,
|
||||||
trend_direction="-",
|
trend_direction="-",
|
||||||
),
|
),
|
||||||
|
|
@ -475,53 +475,6 @@ class DataProcessor:
|
||||||
top_regions=top_regions,
|
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
|
@staticmethod
|
||||||
def _translate_country_code(code: str) -> str:
|
def _translate_country_code(code: str) -> str:
|
||||||
"""국가 코드를 한국어로 변환
|
"""국가 코드를 한국어로 변환
|
||||||
|
|
|
||||||
|
|
@ -44,19 +44,22 @@ class YouTubeAnalyticsService:
|
||||||
"""YouTube Analytics API 호출을 병렬로 실행
|
"""YouTube Analytics API 호출을 병렬로 실행
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
video_ids: YouTube 영상 ID 리스트 (최대 30개)
|
video_ids: YouTube 영상 ID 리스트 (최대 30개, 빈 리스트 허용)
|
||||||
start_date: 조회 시작일 (YYYY-MM-DD)
|
start_date: 조회 시작일 (YYYY-MM-DD)
|
||||||
end_date: 조회 종료일 (YYYY-MM-DD)
|
end_date: 조회 종료일 (YYYY-MM-DD)
|
||||||
access_token: YouTube OAuth 2.0 액세스 토큰
|
access_token: YouTube OAuth 2.0 액세스 토큰
|
||||||
|
mode: 조회 모드 ("month" | "day")
|
||||||
|
kpi_end_date: KPI 집계 종료일 (미전달 시 end_date와 동일)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: API 응답 데이터
|
dict[str, Any]: API 응답 데이터 (7개 키)
|
||||||
- kpi: KPI 메트릭 (조회수, 좋아요, 댓글 등)
|
- kpi: 최근 기간 KPI 메트릭 (조회수, 좋아요, 댓글 등)
|
||||||
- monthly_recent: 최근 12개월 월별 조회수
|
- kpi_previous: 이전 기간 KPI 메트릭 (trend 계산용)
|
||||||
- monthly_previous: 이전 12개월 월별 조회수
|
- trend_recent: 최근 기간 추이 (월별 또는 일별 조회수)
|
||||||
- top_videos: 인기 영상 TOP 4
|
- trend_previous: 이전 기간 추이 (전년 또는 이전 30일)
|
||||||
- demographics: 연령/성별 분포
|
- top_videos: 조회수 기준 인기 영상 TOP 4
|
||||||
- region: 지역별 조회수
|
- demographics: 연령/성별 시청자 분포
|
||||||
|
- region: 지역별 조회수 TOP 5
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
YouTubeAPIError: API 호출 실패
|
YouTubeAPIError: API 호출 실패
|
||||||
|
|
@ -73,7 +76,7 @@ class YouTubeAnalyticsService:
|
||||||
... )
|
... )
|
||||||
"""
|
"""
|
||||||
logger.debug(
|
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}"
|
f"video_count={len(video_ids)}, period={start_date}~{end_date}, mode={mode}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -255,7 +258,7 @@ class YouTubeAnalyticsService:
|
||||||
"endDate": end_date,
|
"endDate": end_date,
|
||||||
"dimensions": "month",
|
"dimensions": "month",
|
||||||
"metrics": "views",
|
"metrics": "views",
|
||||||
# "filters": f"video=={','.join(video_ids)}",
|
"filters": f"video=={','.join(video_ids)}",
|
||||||
"sort": "month",
|
"sort": "month",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -299,7 +302,7 @@ class YouTubeAnalyticsService:
|
||||||
"endDate": end_date,
|
"endDate": end_date,
|
||||||
"dimensions": "day",
|
"dimensions": "day",
|
||||||
"metrics": "views",
|
"metrics": "views",
|
||||||
# "filters": f"video=={','.join(video_ids)}",
|
"filters": f"video=={','.join(video_ids)}",
|
||||||
"sort": "day",
|
"sort": "day",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,7 +343,7 @@ class YouTubeAnalyticsService:
|
||||||
"endDate": end_date,
|
"endDate": end_date,
|
||||||
"dimensions": "video",
|
"dimensions": "video",
|
||||||
"metrics": "views,likes,comments",
|
"metrics": "views,likes,comments",
|
||||||
# "filters": f"video=={','.join(video_ids)}",
|
"filters": f"video=={','.join(video_ids)}",
|
||||||
"sort": "-views",
|
"sort": "-views",
|
||||||
"maxResults": "4",
|
"maxResults": "4",
|
||||||
}
|
}
|
||||||
|
|
@ -410,7 +413,7 @@ class YouTubeAnalyticsService:
|
||||||
rows = [["KR", 1000000], ["US", 500000], ...]
|
rows = [["KR", 1000000], ["US", 500000], ...]
|
||||||
조회수 내림차순으로 정렬된 상위 5개 국가
|
조회수 내림차순으로 정렬된 상위 5개 국가
|
||||||
"""
|
"""
|
||||||
logger.debug("[YouTubeAnalyticsService._fetch_country] START")
|
logger.debug("[YouTubeAnalyticsService._fetch_region] START")
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"ids": "channel==MINE",
|
"ids": "channel==MINE",
|
||||||
|
|
@ -418,7 +421,7 @@ class YouTubeAnalyticsService:
|
||||||
"endDate": end_date,
|
"endDate": end_date,
|
||||||
"dimensions": "country",
|
"dimensions": "country",
|
||||||
"metrics": "views",
|
"metrics": "views",
|
||||||
# "filters": f"video=={','.join(video_ids)}",
|
"filters": f"video=={','.join(video_ids)}",
|
||||||
"sort": "-views",
|
"sort": "-views",
|
||||||
"maxResults": "5",
|
"maxResults": "5",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue