diff --git a/app/user/api/routers/v1/auth.py b/app/user/api/routers/v1/auth.py index c4a65ce..1fa850e 100644 --- a/app/user/api/routers/v1/auth.py +++ b/app/user/api/routers/v1/auth.py @@ -158,10 +158,12 @@ async def kakao_callback( logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}") # 프론트엔드로 토큰과 함께 리다이렉트 + # is_new_user: 프론트가 신규 가입 시에만 Meta CompleteRegistration 전환 추적을 호출하도록 전달 redirect_url = ( f"{prj_settings.PROJECT_DOMAIN}" f"?access_token={result.access_token}" f"&refresh_token={result.refresh_token}" + f"&is_new_user={str(result.is_new_user).lower()}" ) logger.info( f"[ROUTER] 카카오 콜백 완료, 프론트엔드로 리다이렉트 - redirect_url: {redirect_url[:50]}..." diff --git a/app/user/models.py b/app/user/models.py index 6feb033..c9e1a1b 100644 --- a/app/user/models.py +++ b/app/user/models.py @@ -219,6 +219,51 @@ class User(Base): 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( Integer, nullable=False, diff --git a/app/utils/meta_capi.py b/app/utils/meta_capi.py new file mode 100644 index 0000000..dc6b5f2 --- /dev/null +++ b/app/utils/meta_capi.py @@ -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 diff --git a/app/video/api/routers/v1/tracking.py b/app/video/api/routers/v1/tracking.py new file mode 100644 index 0000000..97b5410 --- /dev/null +++ b/app/video/api/routers/v1/tracking.py @@ -0,0 +1,277 @@ +""" +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 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 _extract_client_ip(request: Request) -> str | None: + """리버스 프록시 환경을 고려하여 클라이언트 IP를 추출합니다.""" + forwarded_for = request.headers.get("x-forwarded-for") + if forwarded_for: + # "client, proxy1, proxy2" 형식에서 최초 클라이언트 IP 사용 + return forwarded_for.split(",")[0].strip() + return request.client.host if request.client else 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) diff --git a/config.py b/config.py index 0a3458a..c9a4e9b 100644 --- a/config.py +++ b/config.py @@ -573,6 +573,30 @@ class SocialOAuthSettings(BaseSettings): 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): """내부 서버 간 통신 설정""" @@ -631,5 +655,6 @@ kakao_settings = KakaoSettings() jwt_settings = JWTSettings() recovery_settings = RecoverySettings() social_oauth_settings = SocialOAuthSettings() +meta_conversion_settings = MetaConversionSettings() internal_settings = InternalSettings() social_upload_settings = SocialUploadSettings() diff --git a/docs/database-schema/migration_2026_07_23_add_user_first_video_created_at.sql b/docs/database-schema/migration_2026_07_23_add_user_first_video_created_at.sql new file mode 100644 index 0000000..3ebafd4 --- /dev/null +++ b/docs/database-schema/migration_2026_07_23_add_user_first_video_created_at.sql @@ -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`; diff --git a/docs/database-schema/migration_2026_07_23_add_user_utm_registration.sql b/docs/database-schema/migration_2026_07_23_add_user_utm_registration.sql new file mode 100644 index 0000000..774755c --- /dev/null +++ b/docs/database-schema/migration_2026_07_23_add_user_utm_registration.sql @@ -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`; diff --git a/main.py b/main.py index 06ba9d2..9783560 100644 --- a/main.py +++ b/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.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.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.upload import router as social_upload_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(song_router) app.include_router(video_router) +app.include_router(tracking_router) # Meta 전환 추적 라우터 app.include_router(archive_router) # Archive API 라우터 추가 app.include_router(comment_router) # Comment API 라우터 추가 app.include_router(social_oauth_router, prefix="/social") # Social OAuth 라우터 추가