278 lines
12 KiB
Python
278 lines
12 KiB
Python
"""
|
|
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)
|