o2o-castad-backend/app/utils/meta_capi.py

157 lines
5.3 KiB
Python

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