fix: Meta 전환 이벤트 IP 추출 정확도 개선 및 첫 영상 백필 마이그레이션 추가

- _extract_client_ip를 X-Real-IP → X-Forwarded-For → 소켓 peer 순으로
  후보를 순회하며 공인 IP만 반환하도록 변경. 사설/루프백 대역(Docker 172.x,
  127.0.0.1 등)은 Meta가 폐기해 매개변수 전송률만 떨어뜨리므로 전송 생략
- X-Forwarded-For 맨 앞 항목은 클라이언트 위조가 가능하므로 단순 첫 항목
  사용을 중단하고, nginx가 덮어쓰는 X-Real-IP를 우선 사용
- 기존 회원의 first_video_created_at 백필 SQL 추가.
  신규 컬럼이라 전원 NULL이어서 배포 후 기존 회원이 영상을 추가 생성하면
  FirstVideoCreated가 오발화되는 문제 방지 (배포 전 실행 필요)
This commit is contained in:
김성경 2026-07-28 09:34:27 +09:00
parent 68fc5ebca6
commit 5903211eb9
2 changed files with 97 additions and 4 deletions

View File

@ -19,6 +19,7 @@ Meta 픽셀/Conversions API 전환 이벤트 발화를 위한 엔드포인트를
app.include_router(router) app.include_router(router)
""" """
import ipaddress
import uuid import uuid
from datetime import datetime, timedelta 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: 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") forwarded_for = request.headers.get("x-forwarded-for")
if forwarded_for: if forwarded_for:
# "client, proxy1, proxy2" 형식에서 최초 클라이언트 IP 사용 candidates.extend(part.strip() for part in forwarded_for.split(","))
return forwarded_for.split(",")[0].strip()
return request.client.host if request.client else None 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) @router.post("/meta/first-video-created", response_model=FirstVideoCreatedResponse)

View File

@ -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'
);