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