Compare commits
5 Commits
cbc3f47b54
...
8096837b13
| Author | SHA1 | Date | |
|---|---|---|---|
| 8096837b13 | |||
| 5903211eb9 | |||
| 68fc5ebca6 | |||
| cb8485ecd5 | |||
| 73b05f5cca |
@ -433,6 +433,10 @@ class YouTubeAnalyticsService:
|
|||||||
logger.debug("[YouTubeAnalyticsService._fetch_region] SUCCESS")
|
logger.debug("[YouTubeAnalyticsService._fetch_region] SUCCESS")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# 5xx/네트워크 오류 재시도 설정 (구글 backendError 등 일시적 장애 대응)
|
||||||
|
_MAX_RETRIES = 3
|
||||||
|
_RETRY_BACKOFF_SECONDS = (0.5, 1.0, 2.0)
|
||||||
|
|
||||||
async def _call_api(
|
async def _call_api(
|
||||||
self,
|
self,
|
||||||
params: dict[str, str],
|
params: dict[str, str],
|
||||||
@ -453,51 +457,67 @@ class YouTubeAnalyticsService:
|
|||||||
Raises:
|
Raises:
|
||||||
YouTubeQuotaExceededError: 할당량 초과 (429)
|
YouTubeQuotaExceededError: 할당량 초과 (429)
|
||||||
YouTubeAuthError: 인증 실패 (401, 403)
|
YouTubeAuthError: 인증 실패 (401, 403)
|
||||||
YouTubeAPIError: 기타 API 오류
|
YouTubeAPIError: 기타 API 오류 (5xx/네트워크 오류는 최대 3회 재시도 후 발생)
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
- 타임아웃: 30초
|
- 타임아웃: 30초
|
||||||
- 할당량 초과 시 자동으로 YouTubeQuotaExceededError 발생
|
- 할당량 초과 시 자동으로 YouTubeQuotaExceededError 발생
|
||||||
- 인증 실패 시 자동으로 YouTubeAuthError 발생
|
- 인증 실패 시 자동으로 YouTubeAuthError 발생
|
||||||
|
- 5xx 응답 및 네트워크 오류는 지수 백오프(0.5s→1s→2s)로 최대 3회 재시도
|
||||||
"""
|
"""
|
||||||
headers = {"Authorization": f"Bearer {access_token}"}
|
headers = {"Authorization": f"Bearer {access_token}"}
|
||||||
|
|
||||||
try:
|
last_error: Exception | None = None
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
for attempt in range(self._MAX_RETRIES + 1):
|
||||||
response = await client.get(
|
try:
|
||||||
self.BASE_URL,
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
params=params,
|
response = await client.get(
|
||||||
headers=headers,
|
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()
|
if response.status_code == 429:
|
||||||
|
logger.warning("[YouTubeAnalyticsService._call_api] QUOTA_EXCEEDED")
|
||||||
|
raise YouTubeQuotaExceededError()
|
||||||
|
|
||||||
return response.json()
|
# 인증 실패 체크
|
||||||
|
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}")
|
||||||
|
|
||||||
except (YouTubeAuthError, YouTubeQuotaExceededError):
|
# HTTP 에러 체크
|
||||||
raise # 이미 처리된 예외는 그대로 전파
|
response.raise_for_status()
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
logger.error(
|
return response.json()
|
||||||
f"[YouTubeAnalyticsService._call_api] HTTP_ERROR - "
|
|
||||||
f"status={e.response.status_code}, body={e.response.text[:500]}"
|
except (YouTubeAuthError, YouTubeQuotaExceededError):
|
||||||
)
|
raise # 이미 처리된 예외는 재시도 없이 그대로 전파
|
||||||
raise YouTubeAPIError(f"HTTP {e.response.status_code}")
|
except httpx.HTTPStatusError as e:
|
||||||
except httpx.RequestError as e:
|
logger.error(
|
||||||
logger.error(f"[YouTubeAnalyticsService._call_api] REQUEST_ERROR - {e}")
|
f"[YouTubeAnalyticsService._call_api] HTTP_ERROR - "
|
||||||
raise YouTubeAPIError(f"네트워크 오류: {e}")
|
f"status={e.response.status_code}, body={e.response.text[:500]}"
|
||||||
except Exception as e:
|
)
|
||||||
logger.error(f"[YouTubeAnalyticsService._call_api] UNEXPECTED_ERROR - {e}")
|
# 4xx는 재시도해도 동일하게 실패하므로 즉시 전파, 5xx만 재시도
|
||||||
raise YouTubeAPIError(f"알 수 없는 오류: {e}")
|
if e.response.status_code < 500:
|
||||||
|
raise YouTubeAPIError(f"HTTP {e.response.status_code}")
|
||||||
|
last_error = YouTubeAPIError(f"HTTP {e.response.status_code}")
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"[YouTubeAnalyticsService._call_api] REQUEST_ERROR - {e}")
|
||||||
|
last_error = YouTubeAPIError(f"네트워크 오류: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[YouTubeAnalyticsService._call_api] UNEXPECTED_ERROR - {e}")
|
||||||
|
raise YouTubeAPIError(f"알 수 없는 오류: {e}")
|
||||||
|
|
||||||
|
if attempt < self._MAX_RETRIES:
|
||||||
|
backoff = self._RETRY_BACKOFF_SECONDS[attempt]
|
||||||
|
logger.warning(
|
||||||
|
f"[YouTubeAnalyticsService._call_api] RETRY {attempt + 1}/{self._MAX_RETRIES} "
|
||||||
|
f"in {backoff}s - {last_error}"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(backoff)
|
||||||
|
|
||||||
|
raise last_error
|
||||||
|
|||||||
@ -6,6 +6,8 @@ from fastapi import HTTPException
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from app.dashboard.exceptions import DashboardException
|
||||||
|
from app.social.exceptions import SocialException
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from config import db_settings
|
from config import db_settings
|
||||||
|
|
||||||
@ -137,6 +139,16 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
yield session
|
yield session
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
except (SocialException, DashboardException) as e:
|
||||||
|
# 전역 exception handler가 응답으로 변환하는 도메인 예외.
|
||||||
|
# 정상 플로우이므로 ERROR traceback 없이 롤백만 수행.
|
||||||
|
await session.rollback()
|
||||||
|
logger.warning(
|
||||||
|
f"[get_session] ROLLBACK - handled domain error: "
|
||||||
|
f"{type(e).__name__}: {e}, "
|
||||||
|
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
|
||||||
|
)
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
# status_code < 500인 도메인 예외(계정 미연동 등)는 정상적인 비즈니스 흐름이므로 ERROR로 남기지 않음
|
# status_code < 500인 도메인 예외(계정 미연동 등)는 정상적인 비즈니스 흐름이므로 ERROR로 남기지 않음
|
||||||
|
|||||||
@ -158,10 +158,12 @@ async def kakao_callback(
|
|||||||
logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}")
|
logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}")
|
||||||
|
|
||||||
# 프론트엔드로 토큰과 함께 리다이렉트
|
# 프론트엔드로 토큰과 함께 리다이렉트
|
||||||
|
# is_new_user: 프론트가 신규 가입 시에만 Meta CompleteRegistration 전환 추적을 호출하도록 전달
|
||||||
redirect_url = (
|
redirect_url = (
|
||||||
f"{prj_settings.PROJECT_DOMAIN}"
|
f"{prj_settings.PROJECT_DOMAIN}"
|
||||||
f"?access_token={result.access_token}"
|
f"?access_token={result.access_token}"
|
||||||
f"&refresh_token={result.refresh_token}"
|
f"&refresh_token={result.refresh_token}"
|
||||||
|
f"&is_new_user={str(result.is_new_user).lower()}"
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[ROUTER] 카카오 콜백 완료, 프론트엔드로 리다이렉트 - redirect_url: {redirect_url[:50]}..."
|
f"[ROUTER] 카카오 콜백 완료, 프론트엔드로 리다이렉트 - redirect_url: {redirect_url[:50]}..."
|
||||||
|
|||||||
@ -219,6 +219,51 @@ class User(Base):
|
|||||||
comment="마지막 로그인 일시",
|
comment="마지막 로그인 일시",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
first_video_created_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
nullable=True,
|
||||||
|
comment="첫 영상 생성 완료 일시 (Meta FirstVideoCreated 전환 이벤트 1회 발화 판정용)",
|
||||||
|
)
|
||||||
|
|
||||||
|
registration_tracked_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
nullable=True,
|
||||||
|
comment="가입 전환 추적 일시 (Meta CompleteRegistration 전환 이벤트 1회 발화 판정용)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 광고 유입 경로 (UTM, 가입 시점 first-touch)
|
||||||
|
# ==========================================================================
|
||||||
|
utm_source: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
comment="유입 매체 (예: meta, google, naver)",
|
||||||
|
)
|
||||||
|
|
||||||
|
utm_medium: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
comment="유입 방식 (예: paid_social, cpc)",
|
||||||
|
)
|
||||||
|
|
||||||
|
utm_campaign: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
comment="캠페인 이름",
|
||||||
|
)
|
||||||
|
|
||||||
|
utm_content: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
comment="광고 소재 구분 (A/B 테스트용)",
|
||||||
|
)
|
||||||
|
|
||||||
|
utm_term: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=True,
|
||||||
|
comment="검색 키워드",
|
||||||
|
)
|
||||||
|
|
||||||
credits: Mapped[int] = mapped_column(
|
credits: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|||||||
156
app/utils/meta_capi.py
Normal file
156
app/utils/meta_capi.py
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
"""
|
||||||
|
Meta Conversions API (CAPI) 클라이언트
|
||||||
|
|
||||||
|
Meta 픽셀과 병행하여 서버 사이드 전환 이벤트를 전송합니다.
|
||||||
|
브라우저 픽셀(fbq)과 동일한 event_id를 사용하여 Meta가 중복 이벤트를
|
||||||
|
제거(deduplication)할 수 있도록 합니다.
|
||||||
|
|
||||||
|
전송 규칙 (Meta 요구사항):
|
||||||
|
- external_id 등 개인 식별 정보: SHA-256 해시 후 전송
|
||||||
|
- fbc/fbp 쿠키, client_ip_address, client_user_agent: 원문 그대로 전송
|
||||||
|
- event_time: 유닉스 타임스탬프 (7일 이내)
|
||||||
|
- action_source: "website" 고정
|
||||||
|
|
||||||
|
참고: https://developers.facebook.com/docs/marketing-api/conversions-api
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
from config import meta_conversion_settings
|
||||||
|
|
||||||
|
logger = get_logger("meta_capi")
|
||||||
|
|
||||||
|
# Meta Graph API 버전 및 요청 타임아웃
|
||||||
|
GRAPH_API_VERSION = "v21.0"
|
||||||
|
REQUEST_TIMEOUT = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_hash(value: str) -> str:
|
||||||
|
"""개인 식별 정보를 Meta 매칭 규격에 맞게 SHA-256 해시합니다.
|
||||||
|
|
||||||
|
Meta는 소문자 변환 + 공백 제거 후 해싱을 요구합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: 해시할 원문 문자열 (예: user_uuid, 이메일)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: SHA-256 해시 (16진수 소문자)
|
||||||
|
"""
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
async def send_capi_event(
|
||||||
|
event_name: str,
|
||||||
|
event_id: str,
|
||||||
|
external_id: str,
|
||||||
|
client_ip: str | None = None,
|
||||||
|
client_user_agent: str | None = None,
|
||||||
|
fbc: str | None = None,
|
||||||
|
fbp: str | None = None,
|
||||||
|
event_source_url: str | None = None,
|
||||||
|
custom_data: dict | None = None,
|
||||||
|
test_event_code: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Meta Conversions API로 서버 이벤트를 전송합니다.
|
||||||
|
|
||||||
|
전송 실패는 로깅만 하고 예외를 전파하지 않습니다 (전환 추적 실패가
|
||||||
|
서비스 기능에 영향을 주지 않도록 fire-and-forget 처리).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_name: 이벤트 이름 (브라우저 fbq와 철자/대소문자 일치 필수)
|
||||||
|
event_id: 중복제거용 이벤트 ID (브라우저 fbq의 eventID와 동일해야 함)
|
||||||
|
external_id: 사용자 식별자 원문 (user_uuid). 내부에서 SHA-256 해시됨
|
||||||
|
client_ip: 클라이언트 IP 주소 (원문)
|
||||||
|
client_user_agent: 클라이언트 User-Agent (원문)
|
||||||
|
fbc: Meta 클릭 ID 쿠키(_fbc) 원문
|
||||||
|
fbp: Meta 브라우저 ID 쿠키(_fbp) 원문
|
||||||
|
event_source_url: 이벤트가 발생한 페이지 URL
|
||||||
|
custom_data: 추가 데이터 (예: Purchase의 value/currency)
|
||||||
|
test_event_code: 이벤트 관리자 "테스트 이벤트" 검증용 코드.
|
||||||
|
미지정 시 FACEBOOK_TEST_EVENT_CODE 환경변수 값을 사용 (운영 시 빈 값 유지)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 전송 성공 여부
|
||||||
|
"""
|
||||||
|
pixel_id = meta_conversion_settings.FACEBOOK_PIXEL_ID
|
||||||
|
access_token = meta_conversion_settings.FACEBOOK_ACCESS_TOKEN
|
||||||
|
|
||||||
|
if not pixel_id or not access_token:
|
||||||
|
logger.warning(
|
||||||
|
"[MetaCAPI] SKIP - FACEBOOK_PIXEL_ID/FACEBOOK_ACCESS_TOKEN 미설정 "
|
||||||
|
f"(event_name: {event_name}, event_id: {event_id})"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
user_data: dict = {
|
||||||
|
"external_id": [sha256_hash(external_id)],
|
||||||
|
}
|
||||||
|
if client_ip:
|
||||||
|
user_data["client_ip_address"] = client_ip
|
||||||
|
if client_user_agent:
|
||||||
|
user_data["client_user_agent"] = client_user_agent
|
||||||
|
if fbc:
|
||||||
|
user_data["fbc"] = fbc
|
||||||
|
if fbp:
|
||||||
|
user_data["fbp"] = fbp
|
||||||
|
|
||||||
|
event: dict = {
|
||||||
|
"event_name": event_name,
|
||||||
|
"event_time": int(time.time()),
|
||||||
|
"event_id": event_id,
|
||||||
|
"action_source": "website",
|
||||||
|
"user_data": user_data,
|
||||||
|
}
|
||||||
|
if event_source_url:
|
||||||
|
event["event_source_url"] = event_source_url
|
||||||
|
if custom_data:
|
||||||
|
event["custom_data"] = custom_data
|
||||||
|
|
||||||
|
payload: dict = {"data": [event]}
|
||||||
|
effective_test_code = test_event_code or meta_conversion_settings.FACEBOOK_TEST_EVENT_CODE
|
||||||
|
if effective_test_code:
|
||||||
|
payload["test_event_code"] = effective_test_code
|
||||||
|
logger.info(f"[MetaCAPI] TEST MODE - test_event_code: {effective_test_code}")
|
||||||
|
|
||||||
|
url = f"https://graph.facebook.com/{GRAPH_API_VERSION}/{pixel_id}/events"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
json=payload,
|
||||||
|
params={"access_token": access_token},
|
||||||
|
timeout=REQUEST_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
logger.info(
|
||||||
|
f"[MetaCAPI] SUCCESS - event_name: {event_name}, "
|
||||||
|
f"event_id: {event_id}, response: {response.json()}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
f"[MetaCAPI] FAILED - event_name: {event_name}, event_id: {event_id}, "
|
||||||
|
f"status: {response.status_code}, body: {response.text}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.error(
|
||||||
|
f"[MetaCAPI] HTTP ERROR - event_name: {event_name}, "
|
||||||
|
f"event_id: {event_id}, error: {e}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[MetaCAPI] UNEXPECTED ERROR - event_name: {event_name}, "
|
||||||
|
f"event_id: {event_id}, error: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
322
app/video/api/routers/v1/tracking.py
Normal file
322
app/video/api/routers/v1/tracking.py
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
"""
|
||||||
|
Meta 전환 추적(Conversion Tracking) API Router
|
||||||
|
|
||||||
|
Meta 픽셀/Conversions API 전환 이벤트 발화를 위한 엔드포인트를 정의합니다.
|
||||||
|
|
||||||
|
엔드포인트 목록:
|
||||||
|
- POST /tracking/meta/first-video-created: 첫 영상 생성 완료(FirstVideoCreated) 이벤트 발화
|
||||||
|
|
||||||
|
동작 원리 (event_id 중복제거):
|
||||||
|
1. 프론트가 영상 생성 완료(서버 성공 응답)를 확인한 뒤 이 엔드포인트를 호출
|
||||||
|
2. 서버는 completed 상태의 영상 존재를 검증하고,
|
||||||
|
first_video_created_at IS NULL 조건의 원자적 UPDATE로 "계정당 최초 1회"를 판정
|
||||||
|
3. 최초 1회로 판정되면 event_id(UUID)를 생성하여 Conversions API로 서버 이벤트 전송
|
||||||
|
4. 프론트는 응답의 fired=true일 때만 동일한 event_id로 fbq('trackCustom', ...)를 발화
|
||||||
|
→ Meta가 (event_name, event_id) 기준으로 브라우저/서버 이벤트를 중복제거
|
||||||
|
|
||||||
|
사용 예시:
|
||||||
|
from app.video.api.routers.v1.tracking import router
|
||||||
|
app.include_router(router)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import exists, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from app.database.session import get_session
|
||||||
|
from app.home.models import Project
|
||||||
|
from app.user.dependencies.auth import get_current_user
|
||||||
|
from app.user.models import User
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
from app.utils.meta_capi import send_capi_event
|
||||||
|
from app.video.models import Video
|
||||||
|
|
||||||
|
logger = get_logger("tracking")
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/tracking", tags=["Tracking"])
|
||||||
|
|
||||||
|
# 브라우저 fbq('trackCustom', ...)와 철자/대소문자가 완전히 일치해야 중복제거가 동작함
|
||||||
|
FIRST_VIDEO_CREATED_EVENT = "FirstVideoCreated"
|
||||||
|
|
||||||
|
# Meta 표준 이벤트명 (브라우저 fbq('track', ...)와 동일해야 중복제거가 동작함)
|
||||||
|
COMPLETE_REGISTRATION_EVENT = "CompleteRegistration"
|
||||||
|
|
||||||
|
# 배포 이전에 가입한 기존 계정의 오발화 방지용 윈도우
|
||||||
|
# (가입 직후 프론트가 호출하므로 실제로는 수 분 내 도달함)
|
||||||
|
REGISTRATION_TRACK_WINDOW = timedelta(hours=24)
|
||||||
|
|
||||||
|
|
||||||
|
class FirstVideoCreatedRequest(BaseModel):
|
||||||
|
"""FirstVideoCreated 이벤트 발화 요청 스키마
|
||||||
|
|
||||||
|
fbc/fbp는 Meta 픽셀이 서비스 도메인에 심는 1st-party 쿠키로,
|
||||||
|
API 서버 도메인이 달라 요청에 자동 포함되지 않으므로 프론트가
|
||||||
|
document.cookie에서 읽어 body로 전달합니다. (해싱 금지, 원문 그대로)
|
||||||
|
"""
|
||||||
|
|
||||||
|
fbc: str | None = Field(default=None, description="Meta 클릭 ID 쿠키(_fbc) 원문")
|
||||||
|
fbp: str | None = Field(default=None, description="Meta 브라우저 ID 쿠키(_fbp) 원문")
|
||||||
|
event_source_url: str | None = Field(
|
||||||
|
default=None, description="이벤트가 발생한 페이지 URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FirstVideoCreatedResponse(BaseModel):
|
||||||
|
"""FirstVideoCreated 이벤트 발화 응답 스키마"""
|
||||||
|
|
||||||
|
fired: bool = Field(description="이번 요청으로 이벤트가 최초 발화되었는지 여부")
|
||||||
|
event_id: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="브라우저 fbq 발화 시 사용할 event_id (fired=true일 때만 제공)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_public_ip(value: str) -> bool:
|
||||||
|
"""Meta 매칭에 사용 가능한 공인 IP인지 검사합니다.
|
||||||
|
|
||||||
|
사설/루프백 대역(Docker 내부 172.x, 로컬 127.0.0.1 등)을 보내면
|
||||||
|
Meta가 유효하지 않은 값으로 폐기하면서 매개변수 전송률만 깎이므로,
|
||||||
|
공인 IP가 아니면 아예 전송하지 않기 위해 사용합니다.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(value)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return not (
|
||||||
|
ip.is_private
|
||||||
|
or ip.is_loopback
|
||||||
|
or ip.is_link_local
|
||||||
|
or ip.is_reserved
|
||||||
|
or ip.is_multicast
|
||||||
|
or ip.is_unspecified
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_client_ip(request: Request) -> str | None:
|
||||||
|
"""리버스 프록시 환경을 고려하여 클라이언트 공인 IP를 추출합니다.
|
||||||
|
|
||||||
|
후보를 순서대로 검사하여 처음 발견된 공인 IP를 반환합니다.
|
||||||
|
1. X-Real-IP: nginx가 $remote_addr로 덮어쓰므로 위조 불가 (최우선)
|
||||||
|
2. X-Forwarded-For: 클라이언트가 보낸 값 뒤에 nginx가 덧붙이는 구조라
|
||||||
|
맨 앞 항목이 위조될 수 있으므로, 항목을 순회하며 공인 IP를 찾음
|
||||||
|
3. 소켓 peer 주소: 프록시가 없는 환경 대비
|
||||||
|
|
||||||
|
공인 IP를 하나도 찾지 못하면 None을 반환합니다 (전송 생략).
|
||||||
|
"""
|
||||||
|
candidates: list[str] = []
|
||||||
|
|
||||||
|
real_ip = request.headers.get("x-real-ip")
|
||||||
|
if real_ip:
|
||||||
|
candidates.append(real_ip.strip())
|
||||||
|
|
||||||
|
forwarded_for = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded_for:
|
||||||
|
candidates.extend(part.strip() for part in forwarded_for.split(","))
|
||||||
|
|
||||||
|
if request.client:
|
||||||
|
candidates.append(request.client.host)
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
if _is_public_ip(candidate):
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/meta/first-video-created", response_model=FirstVideoCreatedResponse)
|
||||||
|
async def track_first_video_created(
|
||||||
|
body: FirstVideoCreatedRequest,
|
||||||
|
request: Request,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> FirstVideoCreatedResponse:
|
||||||
|
"""첫 영상 생성 완료(FirstVideoCreated) 전환 이벤트를 발화합니다.
|
||||||
|
|
||||||
|
계정당 최초 1회만 발화됩니다. 판정은 프론트 상태가 아니라
|
||||||
|
User.first_video_created_at 컬럼(null 여부)으로 서버에서 수행하므로
|
||||||
|
새로고침/재호출에도 중복 발화되지 않습니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
body: fbc/fbp 쿠키 및 이벤트 소스 URL
|
||||||
|
request: IP/User-Agent 추출용 요청 객체
|
||||||
|
current_user: 인증된 사용자
|
||||||
|
session: DB 세션
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FirstVideoCreatedResponse: 발화 여부 및 event_id
|
||||||
|
"""
|
||||||
|
# 1. 이미 발화된 계정이면 즉시 종료 (원자적 UPDATE 전 빠른 경로)
|
||||||
|
if current_user.first_video_created_at is not None:
|
||||||
|
return FirstVideoCreatedResponse(fired=False)
|
||||||
|
|
||||||
|
# 2. 서버 기준 "영상 생성 성공" 검증: completed 상태 영상이 실제로 존재해야 함
|
||||||
|
# (Video는 user_uuid를 직접 갖지 않으므로 Project(소유자)를 경유하여 조회)
|
||||||
|
completed_exists = await session.scalar(
|
||||||
|
select(
|
||||||
|
exists().where(
|
||||||
|
Video.project_id == Project.id,
|
||||||
|
Project.user_uuid == current_user.user_uuid,
|
||||||
|
Video.status == "completed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not completed_exists:
|
||||||
|
logger.warning(
|
||||||
|
f"[FirstVideoCreated] completed 영상 없음 - user_uuid: {current_user.user_uuid}"
|
||||||
|
)
|
||||||
|
return FirstVideoCreatedResponse(fired=False)
|
||||||
|
|
||||||
|
# 3. 원자적 최초 1회 판정: first_video_created_at IS NULL인 경우에만 기록
|
||||||
|
# (동시 요청이 와도 rowcount=1은 단 한 요청만 가져감)
|
||||||
|
result = await session.execute(
|
||||||
|
update(User)
|
||||||
|
.where(
|
||||||
|
User.id == current_user.id,
|
||||||
|
User.first_video_created_at.is_(None),
|
||||||
|
)
|
||||||
|
.values(first_video_created_at=func.now())
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
if result.rowcount != 1:
|
||||||
|
# 동시 요청 등으로 다른 요청이 먼저 발화한 경우
|
||||||
|
return FirstVideoCreatedResponse(fired=False)
|
||||||
|
|
||||||
|
# 4. CAPI 서버 이벤트 전송 (실패해도 first_video_created_at은 유지 —
|
||||||
|
# 브라우저 픽셀 발화가 백업 경로가 됨)
|
||||||
|
event_id = str(uuid.uuid4())
|
||||||
|
await send_capi_event(
|
||||||
|
event_name=FIRST_VIDEO_CREATED_EVENT,
|
||||||
|
event_id=event_id,
|
||||||
|
external_id=current_user.user_uuid,
|
||||||
|
client_ip=_extract_client_ip(request),
|
||||||
|
client_user_agent=request.headers.get("user-agent"),
|
||||||
|
fbc=body.fbc,
|
||||||
|
fbp=body.fbp,
|
||||||
|
event_source_url=body.event_source_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[FirstVideoCreated] FIRED - user_uuid: {current_user.user_uuid}, "
|
||||||
|
f"event_id: {event_id}"
|
||||||
|
)
|
||||||
|
return FirstVideoCreatedResponse(fired=True, event_id=event_id)
|
||||||
|
|
||||||
|
|
||||||
|
class CompleteRegistrationRequest(BaseModel):
|
||||||
|
"""CompleteRegistration 이벤트 발화 요청 스키마
|
||||||
|
|
||||||
|
fbc/fbp는 FirstVideoCreatedRequest와 동일하게 프론트가 쿠키 원문을 전달합니다.
|
||||||
|
UTM 5종은 프론트가 랜딩 최초 진입 시 URL에서 캡처해 localStorage에
|
||||||
|
보관하다가 가입 완료 시점에 함께 전달합니다 (first-touch 보존).
|
||||||
|
"""
|
||||||
|
|
||||||
|
fbc: str | None = Field(default=None, description="Meta 클릭 ID 쿠키(_fbc) 원문")
|
||||||
|
fbp: str | None = Field(default=None, description="Meta 브라우저 ID 쿠키(_fbp) 원문")
|
||||||
|
event_source_url: str | None = Field(
|
||||||
|
default=None, description="이벤트가 발생한 페이지 URL"
|
||||||
|
)
|
||||||
|
utm_source: str | None = Field(default=None, description="유입 매체", max_length=255)
|
||||||
|
utm_medium: str | None = Field(default=None, description="유입 방식", max_length=255)
|
||||||
|
utm_campaign: str | None = Field(default=None, description="캠페인 이름", max_length=255)
|
||||||
|
utm_content: str | None = Field(default=None, description="광고 소재 구분", max_length=255)
|
||||||
|
utm_term: str | None = Field(default=None, description="검색 키워드", max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class CompleteRegistrationResponse(BaseModel):
|
||||||
|
"""CompleteRegistration 이벤트 발화 응답 스키마"""
|
||||||
|
|
||||||
|
fired: bool = Field(description="이번 요청으로 이벤트가 최초 발화되었는지 여부")
|
||||||
|
event_id: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="브라우저 fbq 발화 시 사용할 event_id (fired=true일 때만 제공)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/meta/complete-registration", response_model=CompleteRegistrationResponse)
|
||||||
|
async def track_complete_registration(
|
||||||
|
body: CompleteRegistrationRequest,
|
||||||
|
request: Request,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> CompleteRegistrationResponse:
|
||||||
|
"""회원가입 완료(CompleteRegistration) 전환 이벤트를 발화하고 UTM을 기록합니다.
|
||||||
|
|
||||||
|
계정당 최초 1회만 발화됩니다. 판정은 registration_tracked_at 컬럼(null 여부)의
|
||||||
|
원자적 UPDATE로 수행하며, 같은 UPDATE에서 UTM 5종(first-touch)을 함께 저장합니다.
|
||||||
|
|
||||||
|
가입 완료 판정: JWT 인증된 유저의 존재 자체가 서버 기준 가입 성공이며,
|
||||||
|
배포 이전 기존 계정의 오발화를 막기 위해 생성 24시간 이내 계정만 허용합니다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
body: fbc/fbp 쿠키, UTM 5종, 이벤트 소스 URL
|
||||||
|
request: IP/User-Agent 추출용 요청 객체
|
||||||
|
current_user: 인증된 사용자
|
||||||
|
session: DB 세션
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CompleteRegistrationResponse: 발화 여부 및 event_id
|
||||||
|
"""
|
||||||
|
# 1. 이미 발화된 계정이면 즉시 종료 (원자적 UPDATE 전 빠른 경로)
|
||||||
|
if current_user.registration_tracked_at is not None:
|
||||||
|
return CompleteRegistrationResponse(fired=False)
|
||||||
|
|
||||||
|
# 2. 배포 이전 가입한 기존 계정 오발화 방지 (신규 가입 직후 호출만 허용)
|
||||||
|
cutoff = datetime.now() - REGISTRATION_TRACK_WINDOW
|
||||||
|
if current_user.created_at < cutoff:
|
||||||
|
logger.warning(
|
||||||
|
f"[CompleteRegistration] 가입 24시간 경과 계정 - "
|
||||||
|
f"user_uuid: {current_user.user_uuid}, created_at: {current_user.created_at}"
|
||||||
|
)
|
||||||
|
return CompleteRegistrationResponse(fired=False)
|
||||||
|
|
||||||
|
# 3. 원자적 최초 1회 판정 + UTM first-touch 동시 기록
|
||||||
|
# (동시 요청이 와도 rowcount=1은 단 한 요청만 가져감)
|
||||||
|
result = await session.execute(
|
||||||
|
update(User)
|
||||||
|
.where(
|
||||||
|
User.id == current_user.id,
|
||||||
|
User.registration_tracked_at.is_(None),
|
||||||
|
)
|
||||||
|
.values(
|
||||||
|
registration_tracked_at=func.now(),
|
||||||
|
utm_source=body.utm_source,
|
||||||
|
utm_medium=body.utm_medium,
|
||||||
|
utm_campaign=body.utm_campaign,
|
||||||
|
utm_content=body.utm_content,
|
||||||
|
utm_term=body.utm_term,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
if result.rowcount != 1:
|
||||||
|
# 동시 요청 등으로 다른 요청이 먼저 발화한 경우
|
||||||
|
return CompleteRegistrationResponse(fired=False)
|
||||||
|
|
||||||
|
# 4. CAPI 서버 이벤트 전송 (실패해도 registration_tracked_at은 유지 —
|
||||||
|
# 브라우저 픽셀 발화가 백업 경로가 됨)
|
||||||
|
event_id = str(uuid.uuid4())
|
||||||
|
await send_capi_event(
|
||||||
|
event_name=COMPLETE_REGISTRATION_EVENT,
|
||||||
|
event_id=event_id,
|
||||||
|
external_id=current_user.user_uuid,
|
||||||
|
client_ip=_extract_client_ip(request),
|
||||||
|
client_user_agent=request.headers.get("user-agent"),
|
||||||
|
fbc=body.fbc,
|
||||||
|
fbp=body.fbp,
|
||||||
|
event_source_url=body.event_source_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[CompleteRegistration] FIRED - user_uuid: {current_user.user_uuid}, "
|
||||||
|
f"event_id: {event_id}, utm_source: {body.utm_source}, "
|
||||||
|
f"utm_campaign: {body.utm_campaign}"
|
||||||
|
)
|
||||||
|
return CompleteRegistrationResponse(fired=True, event_id=event_id)
|
||||||
@ -356,6 +356,9 @@ async def generate_video(
|
|||||||
creatomate_service = CreatomateService(
|
creatomate_service = CreatomateService(
|
||||||
orientation=orientation,
|
orientation=orientation,
|
||||||
industry=industry,
|
industry=industry,
|
||||||
|
# 미매핑 업종(general 등)은 project_id % len(VST_LIST)로 템플릿을 분배하므로,
|
||||||
|
# 사전 이미지 배정 단계(creative_assets_task)와 동일 템플릿을 받으려면 필수
|
||||||
|
project_id=project_id,
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[generate_video] Using template_id: {creatomate_service.template_id}, (song duration: {song_duration})"
|
f"[generate_video] Using template_id: {creatomate_service.template_id}, (song duration: {song_duration})"
|
||||||
|
|||||||
25
config.py
25
config.py
@ -573,6 +573,30 @@ class SocialOAuthSettings(BaseSettings):
|
|||||||
model_config = _base_config
|
model_config = _base_config
|
||||||
|
|
||||||
|
|
||||||
|
class MetaConversionSettings(BaseSettings):
|
||||||
|
"""Meta 픽셀 / Conversions API 설정
|
||||||
|
|
||||||
|
광고 전환 추적(픽셀 + 서버사이드 CAPI)을 위한 설정입니다.
|
||||||
|
Meta 이벤트 관리자 > 데이터 세트에서 Pixel ID와 액세스 토큰을 발급받습니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
FACEBOOK_PIXEL_ID: str = Field(
|
||||||
|
default="",
|
||||||
|
description="Meta 픽셀(데이터 세트) ID",
|
||||||
|
)
|
||||||
|
FACEBOOK_ACCESS_TOKEN: str = Field(
|
||||||
|
default="",
|
||||||
|
description="Conversions API 시스템 생성 액세스 토큰",
|
||||||
|
)
|
||||||
|
FACEBOOK_TEST_EVENT_CODE: str = Field(
|
||||||
|
default="",
|
||||||
|
description="이벤트 관리자 '테스트 이벤트' 검증용 코드 (예: TEST12345). "
|
||||||
|
"설정 시 CAPI 이벤트가 테스트 이벤트 탭으로 격리됨. 운영 시 빈 값 유지",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = _base_config
|
||||||
|
|
||||||
|
|
||||||
class InternalSettings(BaseSettings):
|
class InternalSettings(BaseSettings):
|
||||||
"""내부 서버 간 통신 설정"""
|
"""내부 서버 간 통신 설정"""
|
||||||
|
|
||||||
@ -631,5 +655,6 @@ kakao_settings = KakaoSettings()
|
|||||||
jwt_settings = JWTSettings()
|
jwt_settings = JWTSettings()
|
||||||
recovery_settings = RecoverySettings()
|
recovery_settings = RecoverySettings()
|
||||||
social_oauth_settings = SocialOAuthSettings()
|
social_oauth_settings = SocialOAuthSettings()
|
||||||
|
meta_conversion_settings = MetaConversionSettings()
|
||||||
internal_settings = InternalSettings()
|
internal_settings = InternalSettings()
|
||||||
social_upload_settings = SocialUploadSettings()
|
social_upload_settings = SocialUploadSettings()
|
||||||
|
|||||||
@ -0,0 +1,15 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Migration: user 테이블에 first_video_created_at 컬럼 추가
|
||||||
|
-- Date: 2026-07-23
|
||||||
|
-- Description: Meta 픽셀/Conversions API 전환 이벤트 FirstVideoCreated의
|
||||||
|
-- "계정당 최초 1회 발화" 판정용 컬럼
|
||||||
|
-- - NULL: 아직 첫 영상 생성 완료 이벤트가 발화되지 않은 계정
|
||||||
|
-- - NOT NULL: 발화 완료 (값 = 첫 영상 생성 완료 일시)
|
||||||
|
-- 서버가 first_video_created_at IS NULL 조건의 원자적 UPDATE로
|
||||||
|
-- 최초 1회를 판정하므로 새로고침/동시 요청에도 중복 발화되지 않음
|
||||||
|
-- 관련 코드: app/video/api/routers/v1/tracking.py, app/utils/meta_capi.py
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE `user`
|
||||||
|
ADD COLUMN `first_video_created_at` DATETIME NULL
|
||||||
|
COMMENT '첫 영상 생성 완료 일시 (Meta FirstVideoCreated 전환 이벤트 1회 발화 판정용)' AFTER `last_login_at`;
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Migration: user 테이블에 UTM 5컬럼 + registration_tracked_at 추가
|
||||||
|
-- Date: 2026-07-23
|
||||||
|
-- Description: Meta 픽셀/Conversions API 전환 추적 확장
|
||||||
|
-- 1) registration_tracked_at: CompleteRegistration 전환 이벤트의
|
||||||
|
-- "계정당 최초 1회 발화" 판정용 (first_video_created_at과 동일 패턴)
|
||||||
|
-- - NULL: 아직 가입 전환 이벤트가 발화되지 않은 계정
|
||||||
|
-- - NOT NULL: 발화 완료 (값 = 추적 시점)
|
||||||
|
-- 2) utm_source/medium/campaign/content/term: 광고 유입 경로 보존
|
||||||
|
-- 가입 시점 first-touch UTM을 기록 (Meta 리포트와 내부 DB 대조용)
|
||||||
|
-- registration_tracked_at 기록과 같은 원자적 UPDATE에서 함께 저장됨
|
||||||
|
-- 관련 코드: app/video/api/routers/v1/tracking.py, app/utils/meta_capi.py
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE `user`
|
||||||
|
ADD COLUMN `registration_tracked_at` DATETIME NULL
|
||||||
|
COMMENT '가입 전환 추적 일시 (Meta CompleteRegistration 전환 이벤트 1회 발화 판정용)' AFTER `first_video_created_at`,
|
||||||
|
ADD COLUMN `utm_source` VARCHAR(255) NULL
|
||||||
|
COMMENT '유입 매체 (예: meta, google, naver)' AFTER `registration_tracked_at`,
|
||||||
|
ADD COLUMN `utm_medium` VARCHAR(255) NULL
|
||||||
|
COMMENT '유입 방식 (예: paid_social, cpc)' AFTER `utm_source`,
|
||||||
|
ADD COLUMN `utm_campaign` VARCHAR(255) NULL
|
||||||
|
COMMENT '캠페인 이름' AFTER `utm_medium`,
|
||||||
|
ADD COLUMN `utm_content` VARCHAR(255) NULL
|
||||||
|
COMMENT '광고 소재 구분 (A/B 테스트용)' AFTER `utm_campaign`,
|
||||||
|
ADD COLUMN `utm_term` VARCHAR(255) NULL
|
||||||
|
COMMENT '검색 키워드' AFTER `utm_content`;
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Migration: 기존 회원의 first_video_created_at 백필
|
||||||
|
-- Date: 2026-07-27
|
||||||
|
-- Description: Meta FirstVideoCreated 전환 이벤트 오발화 방지
|
||||||
|
--
|
||||||
|
-- [문제]
|
||||||
|
-- first_video_created_at은 신규 컬럼이라 기존 회원 전원이 NULL이다.
|
||||||
|
-- FirstVideoCreated 발화 조건은 "completed 영상 존재 + 컬럼이 NULL"이므로,
|
||||||
|
-- 배포 후 기존 회원이 영상을 하나 더 만들면 그것이 51번째 영상이어도
|
||||||
|
-- "첫 영상 생성"으로 발화되어 핵심 전환 이벤트가 오염된다.
|
||||||
|
-- (CompleteRegistration은 is_new_user 게이트 + 24시간 가드가 있어 해당 없음)
|
||||||
|
--
|
||||||
|
-- [조치]
|
||||||
|
-- 이미 completed 영상을 보유한 회원은 그 최초 영상의 생성 시각으로
|
||||||
|
-- 컬럼을 채워 "이미 발화 완료" 상태로 만든다.
|
||||||
|
--
|
||||||
|
-- [실행 시점]
|
||||||
|
-- 반드시 백엔드 배포 전에 실행할 것.
|
||||||
|
-- (ALTER TABLE 마이그레이션 2건을 먼저 적용한 뒤 실행)
|
||||||
|
--
|
||||||
|
-- 관련 코드: app/video/api/routers/v1/tracking.py
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- 백필 대상 건수 사전 확인 (실행 전 참고용)
|
||||||
|
-- SELECT COUNT(*) FROM `user` u
|
||||||
|
-- WHERE u.first_video_created_at IS NULL
|
||||||
|
-- AND EXISTS (
|
||||||
|
-- SELECT 1 FROM video v
|
||||||
|
-- JOIN project p ON v.project_id = p.id
|
||||||
|
-- WHERE p.user_uuid = u.user_uuid AND v.status = 'completed'
|
||||||
|
-- );
|
||||||
|
|
||||||
|
UPDATE `user` u
|
||||||
|
SET u.first_video_created_at = (
|
||||||
|
SELECT MIN(v.created_at)
|
||||||
|
FROM video v
|
||||||
|
JOIN project p ON v.project_id = p.id
|
||||||
|
WHERE p.user_uuid = u.user_uuid
|
||||||
|
AND v.status = 'completed'
|
||||||
|
)
|
||||||
|
WHERE u.first_video_created_at IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM video v
|
||||||
|
JOIN project p ON v.project_id = p.id
|
||||||
|
WHERE p.user_uuid = u.user_uuid
|
||||||
|
AND v.status = 'completed'
|
||||||
|
);
|
||||||
2
main.py
2
main.py
@ -20,6 +20,7 @@ from app.lyric.api.routers.v1.lyric import router as lyric_router
|
|||||||
from app.song.api.routers.v1.song import router as song_router
|
from app.song.api.routers.v1.song import router as song_router
|
||||||
from app.sns.api.routers.v1.sns import router as sns_router
|
from app.sns.api.routers.v1.sns import router as sns_router
|
||||||
from app.video.api.routers.v1.video import router as video_router
|
from app.video.api.routers.v1.video import router as video_router
|
||||||
|
from app.video.api.routers.v1.tracking import router as tracking_router
|
||||||
from app.social.api.routers.v1.oauth import router as social_oauth_router
|
from app.social.api.routers.v1.oauth import router as social_oauth_router
|
||||||
from app.social.api.routers.v1.upload import router as social_upload_router
|
from app.social.api.routers.v1.upload import router as social_upload_router
|
||||||
from app.social.api.routers.v1.seo import router as social_seo_router
|
from app.social.api.routers.v1.seo import router as social_seo_router
|
||||||
@ -410,6 +411,7 @@ app.include_router(social_account_router, prefix="/user") # Social Account API
|
|||||||
app.include_router(lyric_router)
|
app.include_router(lyric_router)
|
||||||
app.include_router(song_router)
|
app.include_router(song_router)
|
||||||
app.include_router(video_router)
|
app.include_router(video_router)
|
||||||
|
app.include_router(tracking_router) # Meta 전환 추적 라우터
|
||||||
app.include_router(archive_router) # Archive API 라우터 추가
|
app.include_router(archive_router) # Archive API 라우터 추가
|
||||||
app.include_router(comment_router) # Comment API 라우터 추가
|
app.include_router(comment_router) # Comment API 라우터 추가
|
||||||
app.include_router(social_oauth_router, prefix="/social") # Social OAuth 라우터 추가
|
app.include_router(social_oauth_router, prefix="/social") # Social OAuth 라우터 추가
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user