""" 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