Compare commits
1 Commits
main
...
feature-me
| Author | SHA1 | Date |
|---|---|---|
|
|
73b05f5cca |
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
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`;
|
||||||
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