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:
parent
68fc5ebca6
commit
5903211eb9
@ -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)
|
||||
|
||||
@ -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'
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user