Compare commits

...

5 Commits

Author SHA1 Message Date
8096837b13 유튜브 네트워크 오류 재시도 설정 추가 2026-07-28 16:07:08 +09:00
5903211eb9 fix: Meta 전환 이벤트 IP 추출 정확도 개선 및 첫 영상 백필 마이그레이션 추가
- _extract_client_ip를 X-Real-IP → X-Forwarded-For → 소켓 peer 순으로
  후보를 순회하며 공인 IP만 반환하도록 변경. 사설/루프백 대역(Docker 172.x,
  127.0.0.1 등)은 Meta가 폐기해 매개변수 전송률만 떨어뜨리므로 전송 생략
- X-Forwarded-For 맨 앞 항목은 클라이언트 위조가 가능하므로 단순 첫 항목
  사용을 중단하고, nginx가 덮어쓰는 X-Real-IP를 우선 사용
- 기존 회원의 first_video_created_at 백필 SQL 추가.
  신규 컬럼이라 전원 NULL이어서 배포 후 기존 회원이 영상을 추가 생성하면
  FirstVideoCreated가 오발화되는 문제 방지 (배포 전 실행 필요)
2026-07-28 09:34:27 +09:00
68fc5ebca6 Merge branch 'main' into feature-meta 2026-07-27 15:27:46 +09:00
cb8485ecd5 fix: 도메인 예외 롤백 로깅 개선 및 영상 생성 템플릿 배정 일관성 수정
- get_session에서 SocialException/DashboardException을 별도로 처리하여
  정상 플로우인 도메인 예외는 ERROR traceback 없이 warning 롤백 로그만 남기도록 수정
- generate_video 호출 시 CreatomateService에 project_id를 전달하여,
  미매핑 업종(general 등)의 템플릿 분배가 사전 이미지 배정 단계와
  동일한 템플릿을 사용하도록 일관성 확보
2026-07-27 11:21:01 +09:00
73b05f5cca feat: Meta 전환 추적(CAPI) 및 UTM 어트리뷰션 추가
- Meta Conversions API 클라이언트 추가 (app/utils/meta_capi.py)
- 전환 추적 라우터 추가 (/tracking/meta/first-video-created,
  /tracking/meta/complete-registration) 및 main.py 등록
- User 모델에 전환 1회 판정 컬럼(first_video_created_at,
  registration_tracked_at) 및 가입 시점 first-touch UTM 컬럼 5종 추가
- 카카오 콜백 리다이렉트 URL에 is_new_user 파라미터 추가
  (프론트가 신규 가입 시에만 CompleteRegistration 발화하도록)
- MetaConversionSettings 설정 추가 (FACEBOOK_PIXEL_ID,
  FACEBOOK_ACCESS_TOKEN, FACEBOOK_TEST_EVENT_CODE)
- DB 마이그레이션 SQL 2건 추가 (docs/database-schema/)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 10:28:37 +09:00
12 changed files with 713 additions and 36 deletions

View File

@ -433,6 +433,10 @@ class YouTubeAnalyticsService:
logger.debug("[YouTubeAnalyticsService._fetch_region] SUCCESS")
return result
# 5xx/네트워크 오류 재시도 설정 (구글 backendError 등 일시적 장애 대응)
_MAX_RETRIES = 3
_RETRY_BACKOFF_SECONDS = (0.5, 1.0, 2.0)
async def _call_api(
self,
params: dict[str, str],
@ -453,51 +457,67 @@ class YouTubeAnalyticsService:
Raises:
YouTubeQuotaExceededError: 할당량 초과 (429)
YouTubeAuthError: 인증 실패 (401, 403)
YouTubeAPIError: 기타 API 오류
YouTubeAPIError: 기타 API 오류 (5xx/네트워크 오류는 최대 3회 재시도 후 발생)
Note:
- 타임아웃: 30초
- 할당량 초과 시 자동으로 YouTubeQuotaExceededError 발생
- 인증 실패 시 자동으로 YouTubeAuthError 발생
- 5xx 응답 및 네트워크 오류는 지수 백오프(0.5s→1s→2s)로 최대 3회 재시도
"""
headers = {"Authorization": f"Bearer {access_token}"}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
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}"
last_error: Exception | None = None
for attempt in range(self._MAX_RETRIES + 1):
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
self.BASE_URL,
params=params,
headers=headers,
)
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):
raise # 이미 처리된 예외는 그대로 전파
except httpx.HTTPStatusError as e:
logger.error(
f"[YouTubeAnalyticsService._call_api] HTTP_ERROR - "
f"status={e.response.status_code}, body={e.response.text[:500]}"
)
raise YouTubeAPIError(f"HTTP {e.response.status_code}")
except httpx.RequestError as e:
logger.error(f"[YouTubeAnalyticsService._call_api] REQUEST_ERROR - {e}")
raise YouTubeAPIError(f"네트워크 오류: {e}")
except Exception as e:
logger.error(f"[YouTubeAnalyticsService._call_api] UNEXPECTED_ERROR - {e}")
raise YouTubeAPIError(f"알 수 없는 오류: {e}")
# HTTP 에러 체크
response.raise_for_status()
return response.json()
except (YouTubeAuthError, YouTubeQuotaExceededError):
raise # 이미 처리된 예외는 재시도 없이 그대로 전파
except httpx.HTTPStatusError as e:
logger.error(
f"[YouTubeAnalyticsService._call_api] HTTP_ERROR - "
f"status={e.response.status_code}, body={e.response.text[:500]}"
)
# 4xx는 재시도해도 동일하게 실패하므로 즉시 전파, 5xx만 재시도
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

View File

@ -6,6 +6,8 @@ from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
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 config import db_settings
@ -137,6 +139,16 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
yield session
except HTTPException:
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:
await session.rollback()
# status_code < 500인 도메인 예외(계정 미연동 등)는 정상적인 비즈니스 흐름이므로 ERROR로 남기지 않음

View File

@ -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]}..."

View File

@ -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,

156
app/utils/meta_capi.py Normal file
View 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

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

View File

@ -356,6 +356,9 @@ async def generate_video(
creatomate_service = CreatomateService(
orientation=orientation,
industry=industry,
# 미매핑 업종(general 등)은 project_id % len(VST_LIST)로 템플릿을 분배하므로,
# 사전 이미지 배정 단계(creative_assets_task)와 동일 템플릿을 받으려면 필수
project_id=project_id,
)
logger.debug(
f"[generate_video] Using template_id: {creatomate_service.template_id}, (song duration: {song_duration})"

View File

@ -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()

View File

@ -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`;

View File

@ -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`;

View File

@ -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'
);

View File

@ -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 라우터 추가