Compare commits
1 Commits
feature-me
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
cbc3f47b54 |
|
|
@ -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}, "
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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]}..."
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)
|
|
||||||
25
config.py
25
config.py
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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`;
|
|
||||||
|
|
@ -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`;
|
|
||||||
2
main.py
2
main.py
|
|
@ -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 라우터 추가
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue