From 5903211eb9aab7442aec55a66ede9bce2ec024e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 28 Jul 2026 09:34:27 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20Meta=20=EC=A0=84=ED=99=98=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20IP=20=EC=B6=94=EC=B6=9C=20=EC=A0=95?= =?UTF-8?q?=ED=99=95=EB=8F=84=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=B2=AB?= =?UTF-8?q?=20=EC=98=81=EC=83=81=20=EB=B0=B1=ED=95=84=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _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가 오발화되는 문제 방지 (배포 전 실행 필요) --- app/video/api/routers/v1/tracking.py | 53 +++++++++++++++++-- ..._07_27_backfill_first_video_created_at.sql | 48 +++++++++++++++++ 2 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 docs/database-schema/migration_2026_07_27_backfill_first_video_created_at.sql 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' + );