Compare commits

..

1 Commits

Author SHA1 Message Date
김성경 cbc3f47b54 fix: 썸네일 슬롯 매칭 개선, 가사 스키마 오류 수정, 롤백 로그 레벨 조정
- creatomate: 썸네일 슬롯 한정 subject 일치 시 space_type 페널티 면제,
  subject 가중치 상향 (2.0 → 3.0)
- lyric 스키마: 트레일링 콤마로 tuple이 되어버린 필드 정의 수정,
  중복 genre 필드 제거
- session: 4xx 도메인 예외 롤백 로그를 traceback 없는 WARNING으로 완화

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:11:46 +09:00
11 changed files with 30 additions and 563 deletions

View File

@ -139,6 +139,13 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
raise raise
except Exception as e: except Exception as e:
await session.rollback() await session.rollback()
# status_code < 500인 도메인 예외(계정 미연동 등)는 정상적인 비즈니스 흐름이므로 ERROR로 남기지 않음
if getattr(e, "status_code", 500) < 500:
logger.warning(
f"[get_session] ROLLBACK - client error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
)
else:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
logger.error( logger.error(
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, " f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "

View File

@ -70,11 +70,11 @@ class GenerateLyricRequest(BaseModel):
language: str = Field( language: str = Field(
default="Korean", default="Korean",
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
), )
orientation: Literal["horizontal", "vertical"] = Field( orientation: Literal["horizontal", "vertical"] = Field(
default="vertical", default="vertical",
description="영상 방향 (horizontal: 가로형, vertical: 세로형)", description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
), )
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값") m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)") industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)")
instrumental: bool = Field(default=False, description="BGM 전용 모드 (가사 생성 안 함)") instrumental: bool = Field(default=False, description="BGM 전용 모드 (가사 생성 안 함)")
@ -83,11 +83,6 @@ class GenerateLyricRequest(BaseModel):
description="사용자가 수동으로 고른 음악 장르 (kpop|pop|ballad|hip-hop|rnb|edm|jazz|rock). " description="사용자가 수동으로 고른 음악 장르 (kpop|pop|ballad|hip-hop|rnb|edm|jazz|rock). "
"'자동 선택'인 경우 None으로 전달하면 GPT가 가사 무드에 맞춰 추천", "'자동 선택'인 경우 None으로 전달하면 GPT가 가사 무드에 맞춰 추천",
) )
genre: Optional[str] = Field(
None,
description="사용자가 수동으로 고른 음악 장르 (kpop|pop|ballad|hip-hop|rnb|edm|jazz|rock). "
"'자동 선택'인 경우 None으로 전달하면 GPT가 가사 무드에 맞춰 추천",
)
class GenerateLyricResponse(BaseModel): class GenerateLyricResponse(BaseModel):

View File

@ -158,12 +158,10 @@ 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]}..."

View File

@ -219,51 +219,6 @@ 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,

View File

@ -806,7 +806,7 @@ class CreatomateService:
"""이미지 슬롯에 대한 각 이미지의 매칭 점수를 계산합니다. """이미지 슬롯에 대한 각 이미지의 매칭 점수를 계산합니다.
점수 = (base * narr_mod + NARR_BONUS * narr) * space_type_penalty 점수 = (base * narr_mod + NARR_BONUS * narr) * space_type_penalty
- base: 태그 카테고리 매칭 합산 (최대 5.5) - base: 태그 카테고리 매칭 합산 (최대 6.5 = space_type 2.0 + subject 3.0 + camera 1.0 + motion 0.5)
- narr_mod: [NARR_FLOOR, 1.0] 구간으로 완화된 narrative 계수 - narr_mod: [NARR_FLOOR, 1.0] 구간으로 완화된 narrative 계수
- NARR_BONUS * narr: narrative 독립 보너스 (base=0이어도 신호 유지) - NARR_BONUS * narr: narrative 독립 보너스 (base=0이어도 신호 유지)
- space_type_penalty: 슬롯이 요구하는 space_type과 이미지가 불일치하면 - space_type_penalty: 슬롯이 요구하는 space_type과 이미지가 불일치하면
@ -830,6 +830,20 @@ class CreatomateService:
for image_tag in image_tag_list for image_tag in image_tag_list
] ]
# 썸네일 슬롯 한정: subject가 슬롯 요구와 일치하면 space_type 불일치
# 페널티를 면제한다. 배경: 일부 업종(예: 레스토랑 dining_hall-food_dish)은
# 태깅 관행상 슬롯이 요구하는 subject(음식 클로즈업)가 실제로는 다른
# space_type(table_setting/detail_plating 등)으로 붙는다 — space_type과
# subject가 한 사진에 공존하기 어려운 조합. 이런 슬롯은 space_type 하드
# 필터가 정작 원하는 subject 이미지를 전부 걸러내는 역효과를 낸다.
# 템플릿 슬롯명을 바꾸지 않고 여기서 흡수(썸네일 한정이라 일반 씬 슬롯의
# space_type 하드 필터는 그대로 유지).
slot_subject = slot_tag_dict.get("subject")
if slot_name.endswith(THUMBNAIL_SLOT_MARKER) and slot_subject is not None:
for idx, image_tag in enumerate(image_tag_list):
if slot_subject.value in image_tag.get("subject", []):
space_type_match_list[idx] = True
for slot_tag_cate, slot_tag_item in slot_tag_dict.items(): for slot_tag_cate, slot_tag_item in slot_tag_dict.items():
if slot_tag_cate == "narrative_preference": if slot_tag_cate == "narrative_preference":
slot_tag_narrative = slot_tag_item slot_tag_narrative = slot_tag_item
@ -839,7 +853,7 @@ class CreatomateService:
case "space_type": case "space_type":
weight = 2.0 weight = 2.0
case "subject": case "subject":
weight = 2.0 weight = 3.0
case "camera": case "camera":
weight = 1.0 weight = 1.0
case "motion_recommended": case "motion_recommended":

View File

@ -1,156 +0,0 @@
"""
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

@ -1,277 +0,0 @@
"""
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)

View File

@ -573,30 +573,6 @@ 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):
"""내부 서버 간 통신 설정""" """내부 서버 간 통신 설정"""
@ -655,6 +631,5 @@ 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()

View File

@ -1,15 +0,0 @@
-- ============================================================
-- 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

@ -1,27 +0,0 @@
-- ============================================================
-- 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

@ -20,7 +20,6 @@ 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
@ -411,7 +410,6 @@ 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 라우터 추가