""" 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)