diff --git a/docs/launch-checklist.md b/docs/launch-checklist.md index ef18ba9..b13957c 100644 --- a/docs/launch-checklist.md +++ b/docs/launch-checklist.md @@ -57,3 +57,29 @@ - [ ] pmlegal 문서의 앱 내 링크·시행일 최종 확인 - [ ] 파일럿 QR 인쇄물은 그대로 유효(주소가 `go.pawmate.kr` 라 백엔드 교체 무관) - [ ] iOS `aps-environment` 가 릴리스 빌드에서 production 인지 확인(푸시) + +## 6. 베타 트랙 정의 — 업체 파일럿과 개인 사용자 베타는 별개 + +측정하려는 값이 서로 다른 두 트랙이며, **업체 파일럿만 돌리면 신원 인증 계열 +표본은 하나도 안 모인다**(사장님은 펫 등록도, 사진 게이트 게시글 작성도 안 함). + +| 트랙 | 대상 | 검증 대상 | 문서 | +|---|---|---|---| +| 업체 파일럿 (P1) | 미용업체 5~10곳 | 케어 리포트 발행·열람 흐름 | pilot-onboarding-checklist.md | +| 개인 사용자 베타 | 클로즈드(섭외 업체 지인·신원 확인된 테스터) | 펫 신원 등록(frames_from_video 섀도), 게시글 사진 인증(0.63), video_too_large 발생률 | 본 문서 §7 | + +수용 리스크(결정, 사고 아님): 클로즈드 베타 동안 frames_from_video 는 섀도 +모드라 프레임 바꿔치기 구멍이 열린 채 돌아간다 — 테스터가 신원 확인된 +범위라 노출 대비 측정 가치가 크다고 판단(pmdb #135 에 기록). **오픈 베타로 +넓히기 전에 enforce 전환 여부를 재검토할 것.** + +## 7. 베타 종료 조건 (정식 출시 전 확인) + +실행 항목(§1~5)과 별개로, 아래가 확인돼야 정식 출시로 넘어간다. + +- [ ] (베타 **진입** 조건) 크래시 리포팅 + Edge Function 실패 가시화 + (`ai_unavailable`·`video_too_large`·`internal_error` 알림) — pmdart #157. + 실패가 안 보이는 상태로 데이터 수집 베타를 시작하면 목적의 절반이 사라진다. +- [ ] frames_from_video 표본 **30건** 도달 → 오탐률 확인 → enforce 전환 판단 (pmdb #135) +- [ ] `IDENTITY_PASS_THRESHOLD 0.63` 근거 확보 — post 인증 로그의 identity_score 분포로 통과선 재검토 +- [ ] `video_too_large` 발생률·기기 분포 확인 → 클라 압축 / Files API 판단 (pmdb #136) diff --git a/docs/supabase-api.md b/docs/supabase-api.md index f4c98cf..b905041 100644 --- a/docs/supabase-api.md +++ b/docs/supabase-api.md @@ -23,7 +23,7 @@ | `reset-password` | 전화 OTP 인증 후 비밀번호 재설정 | 없음 (선행 전화 인증 필요) | false | ACTIVE v3 | | `verify-location` | GPS 현장 동네(행정동) 인증 | 커스텀 JWT Bearer | false | ACTIVE v10 | | `verify-post-photo` | 게시글 사진 동일개체 매칭 + 라이브니스(Gemini) | 커스텀 JWT Bearer | false | ACTIVE v9 | -| `enroll-pet-identity` | 펫 신원 등록 (임무 영상 → AI 검증 → 기준 프레임) | 커스텀 JWT Bearer | false | ACTIVE v8 | +| `enroll-pet-identity` | 펫 신원 등록 (인증 영상 → AI 검증(+프레임 출처) → 기준 프레임) | 커스텀 JWT Bearer | false | ACTIVE v8 | | `search-petcafe` | 애견카페 실시간 검색 (네이버 지역검색 프록시) | 커스텀 JWT Bearer | false | ACTIVE v9 | | `resolve-region` | 좌표 → 행정동 역지오코딩 (부수효과 없음) | 커스텀 JWT Bearer | false | ACTIVE v3 | | `invite-guardian` | 공동보호자 초대 (가입자: 인앱 알림 / 미가입: 초대 SMS) | 커스텀 JWT Bearer | false | ACTIVE v1 | @@ -252,21 +252,21 @@ - 401 `unauthorized`, 403 `forbidden`(보호자 아님), 500 `server_misconfigured` / `internal_error` - **내부 로직**: ⓪ `pet_guardians`에서 uid가 petId의 보호자인지 확인 ① `users`의 활동지역 인증 상태 확인 — `is_location_verified`, `last_verified_at`(30일 경과 시 만료 취급), `region_code` ② 모의위치 거절(+`record_photo_verification` 실패 기록) ③ 촬영 좌표를 네이버 역지오코딩 → 행정동 코드가 `users.region_code`와 일치해야 함 ④ `pet_identity_frames`에서 기준 프레임 목록 조회 + Storage `media` 버킷에서 다운로드(없으면 `pet_not_enrolled`) ⑤ **Gemini 2.5 Pro**(구조화 JSON 출력, temperature 0, 429 시 최대 2회 backoff 재시도)로 기준 프레임 N장 + 게시 사진 1장 → 동일 개체 `identity_score` + 라이브니스(`is_real`, dog/cat_real/fake) 판정 ⑥ `is_real && real>fake`(라이브니스) AND `identity_score >= 0.63`(동일 개체) 통과 시 → 사진을 `media//posts/.jpg`로 업로드 → `record_photo_verification` RPC(p_result='pass', p_ttl_min=15, p_pet_id, p_match_score, p_matched=true 등)가 **검증 토큰** 반환(게시글 작성 시 제출). 실패 경로 중 `mock_location`/`geocode_failed`/`region_mismatch`/`not_real_pet`/`identity_mismatch`는 RPC로 실패 기록을 남기지만, `not_verified`/`pet_not_enrolled`/`ai_unavailable`은 기록 없이 반환한다. - **시크릿**: `JWT_SECRET`, `NAVER_MAP_KEY_ID`, `NAVER_MAP_KEY`, `GEMINI_API_KEY`, `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, (`ALLOW_ORIGIN`) -- **정책**: 지역 재인증 주기 30일(`REVERIFY_DAYS`, 위치기반서비스 이용약관 제7조와 일치), 검증 토큰 TTL 15분, 라이브니스 하한 0.70(상수 `AI_REAL_THRESHOLD` — 실제 판정은 `is_real && real>fake` 사용), 동일 개체 통과선 0.63. +- **정책**: 지역 재인증 주기 30일(`REVERIFY_DAYS`, 위치기반서비스 이용약관 제7조와 일치), 검증 토큰 TTL 15분, 라이브니스는 `is_real && real>fake`(사진 1장 판정은 저조도·역광·블러에 민감해 절대 하한 없음 — 도입은 오탐률 측정 후에만), 동일 개체 통과선 0.63. **신뢰 경계 주의**: `lat`/`lng`/`accuracy`/`isMocked`는 클라이언트 자기신고 값이라 직접 POST로 위조 가능 — 서버 권위 판정은 Gemini 이미지 판별뿐이며, 기기 증명(App Attest/Play Integrity)·좌표 타당성 교차검증은 출시 전 과제. ### enroll-pet-identity - **엔드포인트**: `POST /functions/v1/enroll-pet-identity` — verify_jwt=false(수동 검증) - **인증**: **커스텀 JWT Bearer**. -- **요청 바디**: `{ petId: string(필수), challenge: string[](필수, 1개 이상. 알려진 코드: pat_head|hold_paw|scratch_chin|stroke_back|hand_in_frame — 단 서버는 목록을 강제하지 않으며 미지 코드는 문자열 그대로 Gemini 프롬프트에 들어감), videoBase64: string(필수), videoMime?: string(기본 'video/mp4'), frames: string[](필수, base64 3장 이상), mimeType?: string(기본 'image/jpeg') }` +- **요청 바디**: `{ petId: string(필수), videoBase64: string(필수), videoMime?: string(기본 'video/mp4'), frames: string[](필수, base64 3장 이상), mimeType?: string(기본 'image/jpeg') }` — 동작 미션(challenge)은 AI 판별 오탐이 커서 제거됨(스푸핑 차단은 실물·라이브 판별로 유지). - **응답**: - - 200 성공 `{ enrolled: true, species, breed, colors, frameCount, frames: [url...], infoMatch: { species_kind, breed, color, warnings }, warnings, challengePassed: true }` - - 400 `{ enrolled: false, reason: "missing_pet" | "no_video" | "too_few_frames" | "no_challenge" }` / `invalid_json` - - 200 실패 `{ enrolled: false, reason: "ai_unavailable"(+detail/videoKb 진단) | "not_real_pet"(+ai) | "not_consistent_pet"(+ai) | "challenge_failed"(+missing, ai) }` + - 200 성공 `{ enrolled: true, species, breed, colors, frameCount, frames: [url...], infoMatch: { species_kind, breed, color, warnings }, warnings }` + - 400 `{ enrolled: false, reason: "missing_pet" | "no_video" | "too_few_frames" | "video_too_large" }` / `invalid_json` + - 200 실패 `{ enrolled: false, reason: "ai_unavailable" | "not_real_pet"(+ai) | "not_consistent_pet"(+ai) | "species_mismatch"(+ai) }` — `frames_not_from_video`는 **섀도 모드**라 거절 사유로 반환되지 않음(`FRAMES_FROM_VIDEO_ENFORCE=true` 전환 시 활성화) - 401 `unauthorized`, 403 `{ enrolled:false, reason:"not_guardian" }`, 500 `server_misconfigured` / `internal_error` -- **내부 로직**: ① `pet_guardians` 보호자 확인 ② `pets`에서 등록 종(`species_kind`)/품종(`species`)을 **서버가 직접 읽음**(클라 입력 불신) ③ **Gemini 2.5 Pro 영상 판별**(구조화 출력): 실제 살아있는 개/고양이 여부(dog/cat_real/fake ≥ 0.70 & real>fake), 영상 내내 동일 개체(`consistent`), 지시된 무작위 임무(challenge) 수행 여부(`challenges_done`), 추정 품종/털색 ④ 등록정보 교차검증 — 종/품종 불일치는 **소프트 경고**(통과에 영향 없음, `looseBreedMatch` 느슨 비교: 소문자·공백 제거 후 부분 포함, 믹스는 관대) ⑤ 통과 시 **프레임 N장만** `media//pet_identity//.jpg`로 업로드(upsert) + `enroll_pet_identity` RPC(p_pet, p_species, p_breed, p_colors, p_info_match, p_paths, p_urls). **★ 영상은 저장하지 않음**(Gemini 인라인 전송 후 메모리에서 소멸 — Storage/DB 미기록). +- **내부 로직**: ① `pet_guardians` 보호자 확인 ② `pets`에서 등록 종(`species_kind`)/품종(`species`)을 **서버가 직접 읽음**(클라 입력 불신) ③ **Gemini 2.5 Pro 영상+프레임 한 호출 판별**(구조화 출력): 실제 살아있는 개/고양이 여부(dog/cat_real/fake ≥ 0.70 & real>fake), 영상 내내 동일 개체(`consistent`), **`frames[]`가 그 영상의 장면인지(`frames_from_video`) — 검증한 영상과 저장할 기준 프레임의 바꿔치기 차단**, 추정 품종/털색 ④ 등록정보 교차검증 — 종(개↔고양이) 불일치는 **하드 게이트**(`species_mismatch` 거절), 품종 불일치는 소프트 경고(`looseBreedMatch` 느슨 비교: 소문자·공백 제거 후 부분 포함, 믹스는 관대) ⑤ 통과 시 **프레임 N장만** `media//pet_identity//.jpg`로 업로드(upsert) + `enroll_pet_identity` RPC(p_pet, p_species, p_breed, p_colors, p_info_match, p_paths, p_urls). **★ 영상은 저장하지 않음**(Gemini 인라인 전송 후 메모리에서 소멸 — Storage/DB 미기록). 실패 시에도 `photo_verifications(purpose='pet_identity')`에 1행 기록(관리자 실패 조회용). - **시크릿**: `JWT_SECRET`, `GEMINI_API_KEY`, `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, (`ALLOW_ORIGIN`) -- **정책**: 라이브니스 하한 0.70(`ENROLL_REAL_THRESHOLD`), 프레임 최소 3장, 임무 최소 1개(전부 수행돼야 통과). Gemini 429 시 1.5s/3s backoff 최대 2회 재시도. +- **정책**: 라이브니스 하한 0.70(`ENROLL_REAL_THRESHOLD`), 프레임 최소 3장. 프레임↔영상 동일 출처(`frames_from_video`)는 2026-07-29부터 **섀도 모드**(challenge 를 오탐으로 제거한 전례에 따라 측정 후 전환 — 클라 프레임 추출 시각과 Gemini 1fps 샘플링 어긋남이 오탐 소지). **오탐률 측정**: 성공 등록마다 판정 1행을 `photo_verifications`에 기록 — 분모 = `purpose='pet_identity' AND result='pass'` 전체, 분자 = 그중 `fail_reason='frames_not_from_video_shadow'`. `_shadow` 접미사는 강제 전환 후의 실제 거절 행(`result='fail', fail_reason='frames_not_from_video'`)과의 영구 구분자. 판정 행은 즉시 만료(`expires_at=now()`)+`region_matched=false`+`image_url=null`이라 게시글 토큰으로 소비 불가. 인라인 페이로드 합계 한도 19M chars(`video_too_large` — 발생 시 실측값 console 로그 + 실패 행 기록, Gemini 요청당 20MB 보호선). Gemini 429 시 1.5s/3s backoff 최대 2회 재시도. ### search-petcafe diff --git a/docs/supabase-db.md b/docs/supabase-db.md index 6f5d1d2..a48ec5f 100644 --- a/docs/supabase-db.md +++ b/docs/supabase-db.md @@ -890,7 +890,7 @@ AI 반려동물 사진 검증 기록. 실사/생성 이미지 판별 점수, 개 - **FK**: user_id → users.id, pet_id → pets.id - **CHECK**: - `photo_verifications_result_check`: result IN ('pass','fail') - - `photo_verifications_purpose_check`: purpose IN ('reference','post') + - `photo_verifications_purpose_check`: purpose IN ('reference','post','pet_identity') — 'pet_identity'는 20260713112529에서 추가(varchar(20) 확장은 20260713112812) - **인덱스**: - `photo_verifications_user_idx` (user_id, created_at DESC) - `photo_verifications_token_open_idx` (id, 부분: WHERE consumed_at IS NULL AND result='pass') — 미소비 통과 토큰 조회 diff --git a/supabase/functions/enroll-pet-identity/index.ts b/supabase/functions/enroll-pet-identity/index.ts index a7c06d2..1d210fa 100644 --- a/supabase/functions/enroll-pet-identity/index.ts +++ b/supabase/functions/enroll-pet-identity/index.ts @@ -4,7 +4,10 @@ // Authorization: Bearer // // ① 보호자 확인 ② pets 에서 등록 종/품종 읽기(클라 입력 불신) -// ③ Gemini(영상): 실제 살아있는 개/고양이 + 영상 내내 동일 개체 (+ 참고 품종/털색) +// ③ Gemini(영상+프레임 한 호출): 실제 살아있는 개/고양이 + 영상 내내 동일 개체 +// + frames[] 가 그 영상에서 나온 장면인지(frames_from_video) — 기준셋 바꿔치기 차단. +// frames[] 는 클라이언트 주장일 뿐이므로 영상과 바인딩 검증 없이는 신뢰 금지. +// ※ frames_from_video 는 섀도 모드(기록만, 거절 안 함) — FRAMES_FROM_VIDEO_ENFORCE 참조. // ④ 등록정보 교차검증(종=하드 게이트(개↔고양이 불일치 거절), 품종=소프트 경고, 색=기록) // ⑤ 통과: 프레임 N장만 media 업로드 + enroll_pet_identity RPC. ★ 영상은 저장하지 않음 // @@ -23,6 +26,18 @@ const GEMINI_KEY = Deno.env.get("GEMINI_API_KEY"); const ENROLL_REAL_THRESHOLD = 0.70; const GEMINI_MODEL = "gemini-2.5-pro"; // 유료 등급(billing) — 영상/이미지 멀티모달 +// frames_from_video 하드 리젝 스위치. challenge 를 오탐으로 걷어낸 전례가 있어 동종 +// AI 판단을 측정 없이 하드 게이트로 켜지 않는다 — 2026-07-29부터 섀도 모드. +// 성공 등록마다 판정 1행을 photo_verifications 에 남기고(분모=purpose='pet_identity' +// AND result='pass' 전체, 분자=fail_reason='frames_not_from_video_shadow'), +// 오탐률 확인 후 true 로 전환. 오탐 소지: 클라 프레임 추출 시각(800/2500/4000/5500ms) +// 이 Gemini 의 1fps 영상 샘플링 순간과 어긋나 정상 영상도 false 가 뜰 수 있다. +const FRAMES_FROM_VIDEO_ENFORCE = false; + +// Gemini inline 요청 총량 한도(요청당 20MB) 보호선. 초과 시 Gemini 400 이 +// ai_unavailable 로 뭉개지지 않도록 video_too_large 로 구분해 반환한다. +const MAX_INLINE_B64_CHARS = 19_000_000; + /// Gemini 구조화 출력 호출 + 429(한도) 재시도(backoff). async function geminiGenerate(parts: unknown[], schema: object): Promise { const url = @@ -112,27 +127,38 @@ const ENROLL_SCHEMA = { dog_fake: { type: "number" }, cat_fake: { type: "number" }, consistent: { type: "boolean" }, + frames_from_video: { type: "boolean" }, detected_breed: { type: "string" }, coat_colors: { type: "array", items: { type: "string" } }, reason: { type: "string" }, }, required: [ "species", "dog_real", "cat_real", "dog_fake", "cat_fake", - "consistent", "detected_breed", "coat_colors", "reason", + "consistent", "frames_from_video", "detected_breed", "coat_colors", "reason", ], }; -async function verifyEnrollmentVideo(videoBase64: string, videoMime: string) { +async function verifyEnrollmentVideo( + videoBase64: string, + videoMime: string, + frames: string[], + frameMime: string, +) { const prompt = - `이 영상은 반려동물 등록 인증용이다. 판단하라: + `이 영상은 반려동물 등록 인증용이다. 영상 뒤의 정지 이미지들은 이 영상에서 추출했다고 주장되는 프레임이다. 판단하라: (1) 실제 살아있는 개/고양이인가(화면 재촬영·인쇄물·일러스트·인형·AI 생성은 fake) — dog_real/cat_real/dog_fake/cat_fake(0~1). (2) 영상 내내 같은 한 마리(동일 개체)인가 → consistent. -(3) (참고) 추정 품종 detected_breed(한국어, 확실치 않으면 "믹스") 와 주요 털색 coat_colors(예: ["white","tan"]). +(3) 정지 이미지들이 전부 이 영상의 장면인가(같은 개체·같은 배경·같은 촬영 세션) → frames_from_video. 한 장이라도 다른 개체거나 다른 촬영이면 false. +(4) (참고) 추정 품종 detected_breed(한국어, 확실치 않으면 "믹스") 와 주요 털색 coat_colors(예: ["white","tan"]). reason 한국어 80자 이내.`; - const body = await geminiGenerate([ + const parts: unknown[] = [ { text: prompt }, { inline_data: { mime_type: videoMime, data: videoBase64 } }, - ], ENROLL_SCHEMA); + ]; + for (const f of frames) { + parts.push({ inline_data: { mime_type: frameMime, data: f } }); + } + const body = await geminiGenerate(parts, ENROLL_SCHEMA); const txt = body?.candidates?.[0]?.content?.parts?.[0]?.text ?? "{}"; const v = JSON.parse(txt); return { @@ -142,6 +168,7 @@ reason 한국어 80자 이내.`; dog_fake: clamp01(v.dog_fake), cat_fake: clamp01(v.cat_fake), consistent: v.consistent === true, + frames_from_video: v.frames_from_video === true, detected_breed: String(v.detected_breed ?? "").slice(0, 50), coat_colors: Array.isArray(v.coat_colors) ? v.coat_colors.map((s: unknown) => String(s)).slice(0, 6) @@ -230,6 +257,15 @@ Deno.serve(async (req: Request) => { .maybeSingle(); if (!g) return json({ enrolled: false, reason: "not_guardian" }, 403); + // 1-0) 페이로드 한도 — 실측값과 실패 행을 남긴다(발생 빈도를 봐야 19M 이 + // 맞는 선인지, 클라 압축/Files API 도입이 필요한지 판단할 수 있다). + const inlineChars = videoBase64.length + frames.reduce((n, f) => n + f.length, 0); + if (inlineChars > MAX_INLINE_B64_CHARS) { + console.error(`video_too_large inlineChars=${inlineChars} frames=${frames.length}`); + await logEnrollFail(admin, uid, petId, "video_too_large"); + return json({ enrolled: false, reason: "video_too_large" }, 400); + } + // 1-1) 등록값(대조 기준)은 서버가 직접 읽는다 const { data: pet } = await admin .from("pets") @@ -239,10 +275,10 @@ Deno.serve(async (req: Request) => { const regType = (pet?.species_kind ?? "").toLowerCase(); // 'dog'|'cat' const regBreed = (pet?.species ?? "").trim(); - // 2) Gemini 영상 판별 (실물·라이브 + 동일 개체) + // 2) Gemini 영상 판별 (실물·라이브 + 동일 개체 + 프레임↔영상 동일 출처) let ai: Awaited>; try { - ai = await verifyEnrollmentVideo(videoBase64, videoMime); + ai = await verifyEnrollmentVideo(videoBase64, videoMime, frames, mimeType); } catch (e) { console.error("gemini enroll failed", e); await logEnrollFail(admin, uid, petId, "ai_unavailable"); @@ -258,6 +294,14 @@ Deno.serve(async (req: Request) => { await logEnrollFail(admin, uid, petId, "not_consistent_pet", ai); return json({ enrolled: false, reason: "not_consistent_pet", ai }); } + // ★ 기준셋 바꿔치기 차단 — 검증한 영상과 저장할 프레임이 같은 출처여야 한다. + // 이 게이트가 없으면 "아무 라이브 영상 + 다른 개체 사진 frames[]" 조합으로 + // 타 개체가 기준셋으로 등록되어 이후 모든 게시글 매칭이 무력화된다. + // 섀도 모드에서는 거절하지 않는다 — 판정은 성공 경로에서 매 등록 1행 기록. + if (FRAMES_FROM_VIDEO_ENFORCE && !ai.frames_from_video) { + await logEnrollFail(admin, uid, petId, "frames_not_from_video", ai); + return json({ enrolled: false, reason: "frames_not_from_video", ai }); + } const species = ai.dog_real >= ai.cat_real ? "dog" : "cat"; @@ -309,6 +353,31 @@ Deno.serve(async (req: Request) => { return json({ error: "internal_error" }, 500); } + // frames_from_video 판정을 성공 등록마다 1행 기록 — 섀도 오탐률의 분모 + // (purpose='pet_identity' AND result='pass' 전체)와 분자 + // (fail_reason='frames_not_from_video_shadow')를 함께 남긴다. + // _shadow 접미사로 강제 전환 후의 실제 거절 행(result='fail', + // fail_reason='frames_not_from_video')과 영구 구분된다. + // expires_at=now() 즉시 만료 + region_matched=false + image_url=null 이라 + // 게시글 토큰 소비 조건(expires_at>now, region_matched, image_url 일치)에 + // 걸리지 않는다 — 토큰 오용 불가. + const { error: verdictErr } = await admin.from("photo_verifications").insert({ + user_id: uid, + pet_id: petId, + purpose: "pet_identity", + result: "pass", + fail_reason: ai.frames_from_video ? null : "frames_not_from_video_shadow", + expires_at: new Date().toISOString(), + ai_species: ai.species, + ai_dog_real: ai.dog_real, + ai_cat_real: ai.cat_real, + ai_dog_fake: ai.dog_fake, + ai_cat_fake: ai.cat_fake, + ai_pass: ai.frames_from_video, + ai_reason: ai.reason, + }); + if (verdictErr) console.error("frames verdict insert failed", verdictErr); + return json({ enrolled: true, species, diff --git a/supabase/functions/verify-post-photo/index.ts b/supabase/functions/verify-post-photo/index.ts index ae0d150..f80777a 100644 --- a/supabase/functions/verify-post-photo/index.ts +++ b/supabase/functions/verify-post-photo/index.ts @@ -22,7 +22,10 @@ const NAVER_KEY_ID = Deno.env.get("NAVER_MAP_KEY_ID"); const NAVER_KEY = Deno.env.get("NAVER_MAP_KEY"); const GEMINI_KEY = Deno.env.get("GEMINI_API_KEY"); -const AI_REAL_THRESHOLD = 0.70; // 라이브니스(실제 개/고양이) 하한 +// 라이브니스는 is_real && real > fake 조합으로만 판정한다 — 사진 1장 판정은 +// 실내 저조도·역광·모션 블러에 민감해 절대 하한(0.70 류)을 두지 않는다. +// (영상 11초가 근거인 enroll 쪽 ENROLL_REAL_THRESHOLD 와 대칭 맞추기 금지 — +// 모달리티가 다르다. 절대 하한 도입은 오탐률 측정 후에만.) const IDENTITY_PASS_THRESHOLD = 0.63; // 동일 개체 통과선(중간신뢰) const GEMINI_MODEL = "gemini-2.5-pro"; // 유료 등급(billing) — 멀티이미지 const TOKEN_TTL_MIN = 15; @@ -238,6 +241,9 @@ Deno.serve(async (req: Request) => { return json({ error: "invalid_coords" }, 400); } const accuracy = Math.round(Number(p.accuracy ?? 0)) || 0; + // ※ isMocked/lat/lng/accuracy 는 클라이언트 자기신고 값 — 정상 앱의 모의위치 앱만 + // 걸러진다(직접 POST 로 위조 가능). 서버 권위 방어는 Gemini 판정뿐이며, + // 위조 좌표 차단(App Attest/Play Integrity, 이동속도 타당성)은 출시 전 과제. const isMocked = p.isMocked === true; const admin = createClient(SUPABASE_URL, SERVICE_ROLE);