diff --git a/app/video/api/routers/v1/tracking.py b/app/video/api/routers/v1/tracking.py index 97b5410..37aee1f 100644 --- a/app/video/api/routers/v1/tracking.py +++ b/app/video/api/routers/v1/tracking.py @@ -19,6 +19,7 @@ Meta 픽셀/Conversions API 전환 이벤트 발화를 위한 엔드포인트를 app.include_router(router) """ +import ipaddress import uuid from datetime import datetime, timedelta @@ -76,13 +77,57 @@ class FirstVideoCreatedResponse(BaseModel): ) +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를 추출합니다. + + 후보를 순서대로 검사하여 처음 발견된 공인 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: - # "client, proxy1, proxy2" 형식에서 최초 클라이언트 IP 사용 - return forwarded_for.split(",")[0].strip() - return request.client.host if request.client else None + 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) diff --git a/docs/database-schema/migration_2026_07_27_backfill_first_video_created_at.sql b/docs/database-schema/migration_2026_07_27_backfill_first_video_created_at.sql new file mode 100644 index 0000000..1a1458e --- /dev/null +++ b/docs/database-schema/migration_2026_07_27_backfill_first_video_created_at.sql @@ -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' + );