From f69f41da447751970da02f959b388fce154719a7 Mon Sep 17 00:00:00 2001 From: seizeh Date: Mon, 27 Jul 2026 16:45:38 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EA=B0=84=EC=9D=B4=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90(lite)=20=E2=80=94=20=ED=9B=84=EA=B8=B0=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=EC=A0=84=EC=9A=A9=20=EB=B9=84=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QR·공유 링크로 들어온 손님이 정식 가입 없이 전화번호 인증만으로 후기를 남기게 한다. 진입점은 후기 작성 플로우 하나뿐이다(signup-lite 가 purpose='review' 인증만 받아들이므로 로그인 화면 등 다른 경로로는 계정을 만들 수 없다). 설계의 핵심 둘: 1) **계정은 처음부터 하나다 — 병합이 아니라 승격.** users.phone 에 유니크 인덱스가 있어 같은 번호는 같은 행일 수밖에 없다. 간이로 쓴 뒤 정식 가입하면 새 행을 만드는 게 아니라 그 행에 username/password_hash/ nickname 을 채운다. 덕분에 facility_reviews_of 의 visit_no(user_id 파티션 row_number)가 끊기지 않고 "3번째 방문"으로 이어진다. 반대로 승격을 넣지 않으면 signup_user 가 phone_taken 으로 **정식 가입 자체를 막는다**. 2) **비회원 취급 — 기본 전면 차단, 후기만 예외.** app.uid() 가 status='active' 를 요구하므로 status='lite' 로 두면 RLS·RPC 가 전부 자동으로 막힌다(채팅·게시글·펫 등 어디도 손댈 필요 없음). 후기 작성 하나만 app.uid_lite() 로 뚫는다. 새 기능이 생겨도 기본값이 차단이라 안전하다. 매 작성마다 재인증: add_facility_review 가 lite 계정이면 최근 15분 내 review 인증을 요구한다. 클라이언트만 막으면 우회되므로 서버에서 검사한다. · app.mask_phone — 표시명 `***-1***-**78`. 조합이 1,000가지뿐이라 nickname (대소문자 무시 유니크)에 넣으면 즉시 충돌한다. nickname 에는 비노출 내부값을 넣고 조회 RPC 가 번호에서 마스크를 만들어 돌려준다. · signup-lite — 코드 검증·계정 확보·단명 토큰(15분)을 한 호출에 묶는다. 검증과 발급을 나누면 남이 받은 인증으로 세션을 가로챌 창이 생긴다(정식 signup 은 비밀번호가 한 겹 더 있어 그 창이 없다). · purpose='review' 신설 — send-phone-code 가 signup 목적은 이미 가입된 번호에 발송을 거부하는데, 간이 후기는 정식 회원도 쓸 수 있어 그 차단에 걸리면 안 된다. phone_verifications_purpose_check 제약도 함께 수정(안 하면 INSERT 가 막힌다). · facilities_search 를 anon 에 개방 — 시설은 '주인 없는 프로필' 이라 손님이 상호로 찾아 후기까지 갈 수 있어야 한다. · share_view_load 의 facility_preview 에 id/owner_user_id 추가 — QR 302 대상. 프로덕션 검증(전부 rollback): 마스크 형식, 동의·인증 거부, 같은 번호 재사용, app.uid() 차단 대 uid_lite() 허용, visit_no 1→2, 인증 만료 시 reverify_required, 승격 후 표시명 전환과 회차 유지, 정식 계정 중복 가입 거부 — 13개 항목 통과. Co-Authored-By: Claude Opus 5 (1M context) --- supabase/config.toml | 4 + supabase/functions/send-phone-code/index.ts | 8 +- supabase/functions/signup-lite/index.ts | 139 +++++ supabase/functions/verify-phone-code/index.ts | 7 +- .../20260727100000_lite_reviewer.sql | 539 ++++++++++++++++++ 5 files changed, 693 insertions(+), 4 deletions(-) create mode 100644 supabase/functions/signup-lite/index.ts create mode 100644 supabase/migrations/20260727100000_lite_reviewer.sql diff --git a/supabase/config.toml b/supabase/config.toml index 6ba6201..b70bfbb 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -75,3 +75,7 @@ verify_jwt = true [functions.signup] verify_jwt = true + +# signup-lite: 간이 회원(후기 전용) — 코드 검증·계정·단명 토큰을 한 번에. +[functions.signup-lite] +verify_jwt = true diff --git a/supabase/functions/send-phone-code/index.ts b/supabase/functions/send-phone-code/index.ts index f936acc..2735b2d 100644 --- a/supabase/functions/send-phone-code/index.ts +++ b/supabase/functions/send-phone-code/index.ts @@ -1,11 +1,14 @@ // ============================================================================ // send-phone-code — 전화 인증번호(6자리·5분) 발급 + Solapi SMS 발송 -// POST { phone: string, purpose?: 'signup' | 'password_reset' } +// POST { phone: string, purpose?: 'signup' | 'password_reset' | 'review' } // 흐름: 목적별 계정 존재 검증 → rate limit(동일 번호 60초 1회) → 코드 생성 // → phone_verifications INSERT → Solapi 발송. service_role 로만 DB 접근. // 가입: 이미 가입된 번호 차단(phone_taken) / 재설정: 미가입 번호 차단(user_not_found) // — 불필요한 SMS 발송·오용 방지(같은 정보는 어차피 최종 단계에서 노출되므로 // 계정 열거 위험이 새로 늘지 않음). +// review(간이 후기, 0029): 계정 존재 여부를 **보지 않는다** — 정식 회원도 간이 +// 경로로 후기를 쓸 수 있어야 하는데 signup 목적을 재사용하면 phone_taken 에 +// 막힌다. 목적을 나눠야 후기용 인증이 가입에 전용되는 것도 함께 막힌다. // verify_jwt=false: 가입/비번재설정은 로그인 전 단계라 JWT 없음. 남용은 자체 rate limit 으로 방어. // ============================================================================ import "jsr:@supabase/functions-js/edge-runtime.d.ts"; @@ -15,7 +18,7 @@ import { loadSolapiConfig, normalizePhone, sendSms } from "../_shared/solapi.ts" const CODE_TTL_MIN = 5; const RATE_LIMIT_SEC = 60; -const PURPOSES = new Set(["signup", "password_reset"]); +const PURPOSES = new Set(["signup", "password_reset", "review"]); function genCode(): string { const n = crypto.getRandomValues(new Uint32Array(1))[0] % 1_000_000; @@ -52,6 +55,7 @@ Deno.serve(async (req: Request) => { ); // 0) 목적별 계정 존재 검증 — 가입된 번호에 가입용 문자를 보내지 않는다. + // review 는 어느 분기에도 걸리지 않는다(존재 여부 무관 발송). const { data: existing, error: exErr } = await supabase .from("users").select("id").eq("phone", phone).limit(1).maybeSingle(); if (exErr) { diff --git a/supabase/functions/signup-lite/index.ts b/supabase/functions/signup-lite/index.ts new file mode 100644 index 0000000..edb3e31 --- /dev/null +++ b/supabase/functions/signup-lite/index.ts @@ -0,0 +1,139 @@ +// ============================================================================ +// signup-lite — 간이 회원(후기 전용) 인증 + 계정 확보 + 단명 토큰 발급 (0029 P0) +// POST { phone, code, privacy_consent: true } +// → { ok, token, expires_in, user: { id, display_name } } +// +// **왜 verify-phone-code 를 따로 부르지 않고 여기서 코드까지 검증하나** +// verify-phone-code 는 코드를 사용처리만 하고 끝난다. 그 뒤 별도 호출로 세션을 +// 발급하면, 그 사이 같은 번호로 아무나 signup-lite 를 때려 **남이 받은 인증으로 +// 세션을 가로챌 수 있다**(정식 signup 은 비밀번호가 한 겹 더 있어 이 창이 없다). +// 간이 경로는 코드 소지가 곧 로그인이므로 검증·계정·토큰을 한 호출에 묶는다. +// +// **토큰이 짧은 이유(LITE_TTL=15분)** +// 간이 회원은 세션을 들고 다니지 않는다. 후기 한 건을 쓰는 동안만 유효하고, +// 다음 후기를 쓰려면 문자 인증을 다시 받는다. DB 쪽 add_facility_review 도 +// 같은 15분 창의 review 인증을 요구하므로 둘이 어긋나지 않는다. +// +// 이 함수로 만들어지는 계정은 users.status='lite' 라 app.uid() 에서 제외된다. +// 즉 이 토큰으로는 후기 작성 말고 아무것도 못 한다(채팅·게시글·펫 전부 차단). +// verify_jwt=true: 다른 전화 인증 함수와 같이 publishable 키를 얇은 남용 게이트로. +// ============================================================================ +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "jsr:@supabase/supabase-js@2"; +import { corsHeaders, json } from "../_shared/cors.ts"; +import { clientIp, rateLimited, signAccess } from "../_shared/auth.ts"; +import { normalizePhone } from "../_shared/solapi.ts"; + +const LITE_TTL = 60 * 15; // 15분 — DB 의 reverify 창(add_facility_review)과 동일 + +/// 표시명 — app.mask_phone 과 같은 규칙(010 전체 가림 + 가운데 앞 1 + 끝 2). +/// DB 와 규칙이 갈리면 작성 화면 미리보기와 목록 표시가 달라지므로 함께 고쳐야 한다. +function maskPhone(phone: string): string { + const d = phone.replace(/\D/g, ""); + if (d.length < 4) return "***-****-****"; + if (d.length >= 11) return `***-${d[3]}***-**${d.slice(-2)}`; + return `***-****-**${d.slice(-2)}`; +} + +Deno.serve(async (req: Request) => { + if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); + if (req.method !== "POST") return json({ error: "method_not_allowed" }, 405); + + const secret = Deno.env.get("JWT_SECRET"); + if (!secret) { + console.error("JWT_SECRET 미설정"); + return json({ error: "server_misconfigured" }, 500); + } + + let p: { phone?: string; code?: string; privacy_consent?: boolean }; + try { + p = await req.json(); + } catch { + return json({ error: "invalid_json" }, 400); + } + + const phone = normalizePhone(p.phone ?? ""); + const code = (p.code ?? "").trim(); + if (!/^01\d{8,9}$/.test(phone)) return json({ error: "invalid_phone" }, 400); + if (!/^\d{6}$/.test(code)) return json({ error: "invalid_code" }, 400); + // 동의는 서버에서도 막는다 — 클라이언트 체크박스는 우회 가능하다. + if (p.privacy_consent !== true) return json({ error: "privacy_consent_required" }, 400); + + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!, + ); + + // 코드 대입 방어: 번호당 10회/10분(스푸핑 불가) + IP 30회/10분(보조). + const ip = clientIp(req); + if ( + await rateLimited(supabase, `lite:phone:${phone}`, 10, 600) || + (ip !== null && await rateLimited(supabase, `lite:ip:${ip}`, 30, 600)) + ) { + return json({ error: "rate_limited" }, 429); + } + + // 1) 미사용·미만료 최신 review 코드 확인 + const { data: pv, error: pvErr } = await supabase + .from("phone_verifications") + .select("id, code") + .eq("phone", phone) + .eq("purpose", "review") + .eq("is_used", false) + .gt("expires_at", new Date().toISOString()) + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); + if (pvErr) { + console.error("verify query failed", pvErr); + return json({ error: "internal_error" }, 500); + } + if (!pv || pv.code !== code) { + return json({ verified: false, error: "code_mismatch_or_expired" }, 400); + } + + // 2) 사용 처리(재사용 방지). 여기서 실패하면 토큰을 주면 안 된다. + const { error: usedErr } = await supabase + .from("phone_verifications").update({ is_used: true }).eq("id", pv.id); + if (usedErr) { + console.error("mark used failed", usedErr); + return json({ error: "internal_error" }, 500); + } + + // 3) 계정 확보 — 같은 번호면 기존 계정을 그대로 돌려준다(정식 회원 포함). + // 덕분에 방문 회차(visit_no)가 끊기지 않는다. + const { data: uid, error: suErr } = await supabase.rpc("signup_lite_user", { + p_phone: phone, + p_privacy_consent: true, + }); + if (suErr) { + const msg = suErr.message ?? ""; + if (msg.includes("account_unavailable")) return json({ error: "account_unavailable" }, 403); + if (msg.includes("privacy_consent_required")) { + return json({ error: "privacy_consent_required" }, 400); + } + if (msg.includes("phone_not_verified")) return json({ error: "phone_not_verified" }, 403); + console.error("signup_lite_user failed", suErr); + return json({ error: "internal_error" }, 500); + } + + // 4) tv 클레임 — 미stamp 하면 token_version 이 올라간 계정이 즉시 잠긴다. + // (정식 회원이 간이 경로로 들어온 경우 tv 가 0 이 아닐 수 있다.) + const { data: uRow, error: uErr } = await supabase + .from("users").select("token_version").eq("id", uid).single(); + if (uErr || !uRow) { + console.error("token_version fetch failed", uErr); + return json({ error: "internal_error" }, 500); + } + + const token = await signAccess( + uid as string, (uRow.token_version as number | undefined) ?? 0, LITE_TTL, secret, + ); + + return json({ + ok: true, + token, + expires_in: LITE_TTL, + user: { id: uid, display_name: maskPhone(phone) }, + }); +}); diff --git a/supabase/functions/verify-phone-code/index.ts b/supabase/functions/verify-phone-code/index.ts index 7884aca..bc0cbd9 100644 --- a/supabase/functions/verify-phone-code/index.ts +++ b/supabase/functions/verify-phone-code/index.ts @@ -1,15 +1,18 @@ // ============================================================================ // verify-phone-code — 전화 인증번호 검증 -// POST { phone: string, code: string, purpose?: 'signup' | 'password_reset' } +// POST { phone: string, code: string, purpose?: 'signup' | 'password_reset' | 'review' } // 같은 phone+purpose 의 미사용·미만료 최신 code 일치 확인 → is_used=true. // verify_jwt=false: 로그인 전 단계. service_role 로만 DB 접근. +// ⚠ purpose='review'(간이 후기)는 보통 이 함수를 거치지 않는다 — signup-lite 가 +// 검증·계정생성·토큰발급을 한 번에 한다(코드 소지와 세션 발급을 분리하면 +// 남의 인증을 가로챌 창이 생긴다). 여기 목록에 둔 건 재사용 여지를 위해서다. // ============================================================================ import "jsr:@supabase/functions-js/edge-runtime.d.ts"; import { createClient } from "jsr:@supabase/supabase-js@2"; import { corsHeaders, json } from "../_shared/cors.ts"; import { normalizePhone } from "../_shared/solapi.ts"; -const PURPOSES = new Set(["signup", "password_reset"]); +const PURPOSES = new Set(["signup", "password_reset", "review"]); Deno.serve(async (req: Request) => { if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); diff --git a/supabase/migrations/20260727100000_lite_reviewer.sql b/supabase/migrations/20260727100000_lite_reviewer.sql new file mode 100644 index 0000000..47c1fa2 --- /dev/null +++ b/supabase/migrations/20260727100000_lite_reviewer.sql @@ -0,0 +1,539 @@ +-- 간이 회원(lite) — 후기 작성 전용 비회원 계정 (0029 P0) +-- +-- 목적: QR·공유 링크로 들어온 손님이 정식 가입(아이디·비밀번호·닉네임·약관 전체) +-- 없이 **전화번호 인증만으로 후기를 남기게** 한다. 진입점은 후기 작성 플로우 하나뿐. +-- +-- 설계의 핵심 두 가지: +-- +-- 1) **계정은 처음부터 하나다(병합이 아니라 승격).** +-- users.phone 에 유니크 인덱스(users_phone_uq)가 있어 같은 번호는 같은 행일 +-- 수밖에 없다. 간이로 쓴 뒤 정식 가입하면 새 행을 만드는 게 아니라 그 행에 +-- username/password_hash/nickname 을 채운다. 덕분에 facility_reviews_of 의 +-- visit_no(= user_id 파티션 row_number)가 끊기지 않고 "3번째 방문"으로 이어진다. +-- 반대로 승격을 안 하면 signup_user 가 phone_taken 으로 **정식 가입을 막는다.** +-- +-- 2) **간이 회원은 비회원 취급 — 기본 전면 차단, 후기만 예외.** +-- app.uid() 가 status='active' 를 요구하므로 status='lite' 로 두면 RLS·RPC 가 +-- 전부 자동으로 막힌다(채팅·게시글·펫 등 어디에도 손댈 필요 없음). 후기 작성 +-- 하나만 app.uid_lite() 로 뚫는다. 새 기능이 생겨도 기본값이 차단이라 안전하다. +-- +-- 매 작성마다 재인증: 간이 계정은 세션을 들고 다니지 않는다. 후기를 쓸 때마다 +-- purpose='review' 전화 인증을 새로 받게 하고, 그 사실을 **서버에서** 확인한다 +-- (클라이언트만 막으면 우회된다). + +begin; + +-- ── 1. status 도메인에 'lite' 추가 ──────────────────────────────────────────── +-- CHECK 를 안 고치면 INSERT 가 조용히가 아니라 요란하게 실패한다. 값 추가는 +-- 언제나 제약 수정과 한 세트다. +alter table public.users drop constraint if exists users_status_check; +alter table public.users add constraint users_status_check + check (status in ('active', 'inactive', 'suspended', 'deleted', 'lite')); + +comment on column public.users.status is + 'active=정식 회원 · lite=후기 전용 간이 회원(비회원 취급, app.uid() 에서 제외) · ' + 'inactive=휴면 · suspended=정지 · deleted=탈퇴'; + +-- ── 1-2. 전화 인증 목적에 'review' 추가 ────────────────────────────────────── +-- 간이 후기는 'signup' 목적을 재사용할 수 없다. send-phone-code 가 signup 목적은 +-- **이미 가입된 번호에 발송을 거부**하는데(index.ts:61), 간이 후기는 정식 회원도 +-- 쓸 수 있어야 하기 때문이다. 목적을 분리하면 그 차단과 무관해지고, 후기용 +-- 인증이 가입에 전용되는 일도 막힌다. +alter table public.phone_verifications + drop constraint if exists phone_verifications_purpose_check; +alter table public.phone_verifications add constraint phone_verifications_purpose_check + check (purpose in ('signup', 'password_reset', 'review')); + +-- ── 2. 전화번호 마스크 ──────────────────────────────────────────────────────── +-- 표시 규칙: 앞 3자리(010)는 전부 가리고, 가운데 4자리 중 앞 1자리, 끝 2자리만 +-- 노출한다 → '***-1***-**78'. 본인은 알아보고 남은 식별력은 낮다. +-- +-- ⚠️ 이 값을 users.nickname 에 넣으면 안 된다 — 조합이 1,000가지뿐인데 nickname 은 +-- 대소문자 무시 유니크(users_lower_nickname_uq)라 즉시 충돌한다. nickname 에는 +-- 비노출 내부값(lite_)을 넣고, 표시는 조회 시점에 이 함수로 만든다. +create or replace function app.mask_phone(p_phone text) +returns text +language sql +immutable +set search_path to '' +as $$ + select case + when d is null or length(d) < 4 then '***-****-****' + when length(d) >= 11 then + '***-' || substr(d, 4, 1) || '***-**' || right(d, 2) + -- 10자리 등 비표준 번호는 끝 2자리만 — 자리수를 억지로 맞추지 않는다. + else '***-****-**' || right(d, 2) + end + from (select regexp_replace(coalesce(p_phone, ''), '\D', '', 'g') as d) t; +$$; + +comment on function app.mask_phone(text) is + '간이 회원 표시명 — 010 전체 마스킹 + 가운데 앞 1자리 + 끝 2자리(***-1***-**78).'; + +-- ── 3. 후기 작성용 uid 해석 ────────────────────────────────────────────────── +-- app.uid() 와 같되 status='lite' 도 통과시킨다. **add_facility_review 에서만** +-- 쓴다. 다른 데서 쓰면 간이 회원이 비회원이라는 전제가 무너진다. +create or replace function app.uid_lite() +returns uuid +language sql +stable +security definer +set search_path to '' +as $$ + select u.id + from public.users u + where u.id = nullif((nullif(current_setting('request.jwt.claims', true),'')::jsonb)->>'sub','')::uuid + and u.status in ('active', 'lite') + and u.token_version = coalesce( + ((nullif(current_setting('request.jwt.claims', true),'')::jsonb)->>'tv')::int, 0) +$$; + +comment on function app.uid_lite() is + '후기 작성 전용 uid — active + lite 허용. 이 함수를 다른 기능에 쓰지 말 것' + '(간이 회원은 후기 외 모든 기능에서 비회원이어야 한다).'; + +-- ── 4. 간이 회원 생성 ──────────────────────────────────────────────────────── +-- service_role 전용(엣지 함수 signup-lite 경유). 같은 번호가 이미 있으면 새로 +-- 만들지 않고 그 계정을 그대로 돌려준다 — 재방문·정식 회원의 간이 경로 진입 모두 +-- 같은 계정으로 수렴해야 visit_no 가 이어진다. +create or replace function public.signup_lite_user( + p_phone text, + p_privacy_consent boolean default false +) +returns uuid +language plpgsql +security definer +set search_path to '' +as $$ +declare + v_id uuid; + v_status text; + v_hex text; +begin + -- 개인정보(전화번호) 수집·이용 동의는 필수. 클라이언트 체크박스만 믿지 않는다. + if coalesce(p_privacy_consent, false) is not true then + raise exception 'privacy_consent_required' using errcode = 'P0001'; + end if; + + -- 후기 목적 전화 인증 확인(30분 이내). purpose 를 'signup' 과 분리하는 이유: + -- send-phone-code 가 signup 목적은 "이미 가입된 번호"에 발송을 거부하는데, + -- 간이 후기는 정식 회원이 쓸 수도 있어 그 차단에 걸리면 안 된다. + if not exists ( + select 1 from public.phone_verifications + where phone = p_phone + and purpose = 'review' + and is_used = true + and created_at > now() - interval '30 minutes' + ) then + raise exception 'phone_not_verified' using errcode = 'P0001'; + end if; + + select id, status into v_id, v_status + from public.users where phone = p_phone; + + if found then + -- 정지·탈퇴 계정이 간이 경로로 되살아나면 안 된다. + if v_status in ('suspended', 'deleted') then + raise exception 'account_unavailable' using errcode = 'P0001'; + end if; + return v_id; + end if; + + -- username/nickname 은 NOT NULL + 유니크라 값이 필요하지만 사용자에게는 보이지 + -- 않는다(표시는 app.mask_phone). password_hash 는 argon2id/bcrypt 어느 쪽으로도 + -- 검증에 성공할 수 없는 sentinel — 비밀번호 로그인 경로를 원천 차단한다. + -- gen_random_uuid() 는 pg_catalog 라 search_path='' 에서도 그냥 보인다. + -- (pgcrypto 의 gen_random_bytes 는 extensions 스키마라 여기서 안 보인다.) + v_hex := substr(replace(gen_random_uuid()::text, '-', ''), 1, 12); + + insert into public.users ( + username, password_hash, nickname, user_type, phone, phone_verified, + status, terms_agreed_at + ) values ( + 'lite_' || v_hex, + '!', + 'lite_' || v_hex, + 'no_pet', + p_phone, + true, + 'lite', + now() + ) + returning id into v_id; + + return v_id; +end; +$$; + +revoke all on function public.signup_lite_user(text, boolean) from public, anon, authenticated; +grant execute on function public.signup_lite_user(text, boolean) to service_role; + +comment on function public.signup_lite_user(text, boolean) is + '간이 회원 생성/조회 — 엣지 signup-lite 전용(service_role). 같은 번호는 항상 같은 계정.'; + +-- ── 5. 정식 가입에서 간이 계정 승격 ────────────────────────────────────────── +-- 기존 동작과 달라지는 지점은 하나다: 같은 번호가 **lite** 로 존재하면 +-- phone_taken 대신 그 행을 UPDATE 해서 정식 회원으로 올린다. 이미 쓴 후기는 +-- user_id 가 그대로라 자동으로 따라오고, visit_no 도 이어진다. +create or replace function public.signup_user( + p_username text, + p_password_hash text, + p_nickname text, + p_user_type text, + p_phone text, + p_marketing boolean default false +) +returns uuid +language plpgsql +security definer +set search_path to '' +as $function$ +declare + v_id uuid; + v_status text; +begin + -- 1) 전화 인증 완료 확인 (signup 목적, 사용처리됨, 30분 이내) + if not exists ( + select 1 from public.phone_verifications + where phone = p_phone + and purpose = 'signup' + and is_used = true + and created_at > now() - interval '30 minutes' + ) then + raise exception 'phone_not_verified' using errcode = 'P0001'; + end if; + + -- 2) 중복 사전 검사 (유니크 인덱스가 최종 방어선, 여기선 친절한 에러코드용) + if exists (select 1 from public.users where lower(username) = lower(p_username)) then + raise exception 'username_taken' using errcode = 'P0001'; + end if; + if exists (select 1 from public.users where lower(nickname) = lower(p_nickname)) then + raise exception 'nickname_taken' using errcode = 'P0001'; + end if; + + -- 3) 같은 번호가 이미 있으면 — lite 면 승격, 그 외엔 기존대로 거부. + select id, status into v_id, v_status + from public.users where phone = p_phone; + + if found then + if v_status <> 'lite' then + raise exception 'phone_taken' using errcode = 'P0001'; + end if; + update public.users set + username = p_username, + password_hash = p_password_hash, + nickname = p_nickname, + user_type = p_user_type, + status = 'active', + terms_agreed_at = now(), + marketing_opt_in = coalesce(p_marketing, false), + marketing_opt_in_at = case when coalesce(p_marketing, false) then now() else null end, + updated_at = now() + where id = v_id; + return v_id; + end if; + + -- 4) 신규 INSERT (해시는 엣지에서 argon2id 로 생성, 필수 약관 동의 시각 기록) + insert into public.users ( + username, password_hash, nickname, user_type, phone, phone_verified, + terms_agreed_at, marketing_opt_in, marketing_opt_in_at + ) values ( + p_username, + p_password_hash, + p_nickname, + p_user_type, + p_phone, + true, + now(), + coalesce(p_marketing, false), + case when coalesce(p_marketing, false) then now() else null end + ) + returning id into v_id; + + return v_id; +end; +$function$; + +-- ── 6. 후기 작성 — 간이 회원 허용 + 매 작성 재인증 강제 ────────────────────── +-- 바뀐 곳: app.uid() → app.uid_lite(), 그리고 lite 계정이면 최근 15분 내 +-- purpose='review' 인증을 요구한다. 클라이언트가 아무리 우회해도 여기서 막힌다. +create or replace function public.add_facility_review( + p_facility uuid, + p_rating smallint, + p_body text, + p_paths text[] default '{}'::text[], + p_urls text[] default '{}'::text[], + p_has_incentive boolean default false, + p_videos jsonb default '[]'::jsonb +) +returns uuid +language plpgsql +security definer +set search_path to '' +as $function$ +declare + v_uid uuid := app.uid_lite(); + v_id uuid; + v jsonb; + v_status text; + v_phone text; +begin + if v_uid is null then raise exception 'auth required'; end if; + if p_rating < 1 or p_rating > 5 then raise exception 'rating 1..5'; end if; + + select status, phone into v_status, v_phone + from public.users where id = v_uid; + + -- 간이 회원은 후기 한 건마다 전화 인증을 새로 받는다(세션을 들고 다니지 않는다). + if v_status = 'lite' then + if not exists ( + select 1 from public.phone_verifications + where phone = v_phone + and purpose = 'review' + and is_used = true + and created_at > now() - interval '15 minutes' + ) then + raise exception 'reverify_required' using errcode = 'P0001'; + end if; + end if; + + if exists ( + select 1 from public.business_profiles bp + where bp.user_id = v_uid + and bp.status in ('pending', 'approved') + and bp.matched_facility_id = any(public.facility_sibling_ids(p_facility)) + ) then + raise exception 'own_facility' using errcode = 'P0001'; + end if; + -- 영상 검증 — 배열·개수는 CHECK 가 재검증하지만, 원소 형태는 여기서 명시 거부. + if jsonb_typeof(coalesce(p_videos, '[]'::jsonb)) is distinct from 'array' + or jsonb_array_length(coalesce(p_videos, '[]'::jsonb)) > 2 then + raise exception 'invalid_videos' using errcode = 'P0001'; + end if; + for v in select value from jsonb_array_elements(coalesce(p_videos, '[]'::jsonb)) + loop + if jsonb_typeof(v) is distinct from 'object' + or coalesce(v->>'url', '') = '' or length(v->>'url') > 500 then + raise exception 'invalid_videos' using errcode = 'P0001'; + end if; + end loop; + insert into public.facility_reviews + (facility_id, user_id, rating, content, photo_paths, photo_urls, + has_incentive, videos) + values (p_facility, v_uid, p_rating, p_body, + coalesce(p_paths,'{}'), coalesce(p_urls,'{}'), + coalesce(p_has_incentive, false), coalesce(p_videos, '[]'::jsonb)) + returning id into v_id; + return v_id; +end $function$; + +-- ── 7. 후기 조회 — 간이 회원은 마스크로 표시 ───────────────────────────────── +-- author_nickname **값만** 바꾼다. 컬럼을 추가하면 RETURNS TABLE 이 달라져 +-- create or replace 가 거부되고 drop 이 필요해지는데, 그러면 앱 구버전이 +-- 잠시 깨진다. 값 치환은 그런 위험이 없다. +create or replace function public.facility_reviews_of( + p_facility uuid, + p_limit integer default 20, + p_offset integer default 0 +) +returns table( + id uuid, user_id uuid, author_nickname text, rating smallint, content text, + photo_urls text[], created_at timestamp with time zone, is_mine boolean, + visit_no integer, has_incentive boolean, videos jsonb +) +language sql +stable +security definer +set search_path to '' +as $function$ + select r.id, r.user_id, + case when au.status = 'lite' then app.mask_phone(au.phone) + else pr.nickname end, + r.rating, r.content, r.photo_urls, r.created_at, + (r.user_id = app.uid_lite()) as is_mine, r.visit_no, r.has_incentive, r.videos + from ( + select fr.*, + row_number() over ( + partition by fr.user_id order by fr.created_at + )::int as visit_no + from public.facility_reviews fr + where fr.facility_id = any(public.facility_sibling_ids(p_facility)) + and fr.visibility_status = 'visible' + ) r + left join public.public_profiles pr on pr.id = r.user_id + left join public.users au on au.id = r.user_id + order by r.created_at desc + limit least(p_limit, 50) offset p_offset; +$function$; + +create or replace function public.facility_review_by_id(p_review uuid) +returns table( + id uuid, user_id uuid, author_nickname text, rating smallint, content text, + photo_urls text[], created_at timestamp with time zone, is_mine boolean, + visit_no integer, has_incentive boolean, videos jsonb +) +language sql +stable +security definer +set search_path to '' +as $function$ + select r.id, r.user_id, + case when au.status = 'lite' then app.mask_phone(au.phone) + else pr.nickname end, + r.rating, r.content, r.photo_urls, r.created_at, + (r.user_id = app.uid_lite()) as is_mine, r.visit_no, r.has_incentive, r.videos + from ( + select fr.*, + row_number() over ( + partition by fr.user_id order by fr.created_at + )::int as visit_no + from public.facility_reviews fr + where fr.visibility_status = 'visible' + ) r + left join public.public_profiles pr on pr.id = r.user_id + left join public.users au on au.id = r.user_id + where r.id = p_review; +$function$; + +-- ── 7-2. 시설 이름 검색을 비로그인에 개방 ──────────────────────────────────── +-- 시설은 '주인 없는 프로필'이라 손님이 상호로 찾아 후기까지 갈 수 있어야 하는데, +-- facilities_search 가 authenticated 전용이라 게스트 검색이 빈손으로 돌아왔다. +-- facilities 테이블 자체는 이미 RLS `using (true)` + anon SELECT 라 이 함수만 +-- 열어도 새로 드러나는 정보는 없다(같은 데이터를 더 편하게 찾을 뿐). +-- +-- 반경 조회(facilities_within)는 **열지 않는다** — 웹은 위치 수집을 하드 차단하고 +-- 있어(0028 웹 이식) 좌표 기반 조회를 비로그인에 열 이유가 없다. +grant execute on function public.facilities_search(text, double precision, double precision) to anon; + +-- ── 8. 공유 뷰어 — 후기 작성 딥링크용 facility_id ──────────────────────────── +-- QR(facility_preview)로 들어온 사람은 곧바로 **그 매장의 후기 작성 화면**으로 +-- 보낸다. 매장에서 QR 을 찍는 목적이 후기이기 때문이다. +-- +-- 대상이 업체 프로필이 아니라 시설(facility)인 게 핵심이다: 업체 계정이 없는 +-- 매장이 대부분인데(공공데이터로 들어온 시설이 24,000여 곳), 시설 자체가 이미 +-- '주인 없는 프로필' 노릇을 한다. 나중에 그 매장이 업체 인증을 마치면 +-- business_profiles.matched_facility_id 로 같은 시설에 붙어 수정 권한을 갖는다 — +-- 즉 QR 을 먼저 뿌려도 나중에 주인이 나타나는 순서가 자연스럽게 성립한다. +-- +-- owner_user_id 도 함께 싣는다(업체 프로필로 보내는 `/u/` 링크용 — 현재 QR 은 +-- 쓰지 않지만 공유 경로가 늘어날 때 재조회를 막는다). +create or replace function public.share_view_load(p_token character varying) +returns jsonb +language plpgsql +security definer +set search_path to 'public' +as $function$ +declare + v_link app.share_links%rowtype; + v_out jsonb; +begin + select * into v_link from app.share_links where token = p_token; + if not found or v_link.revoked_at is not null then + return jsonb_build_object('status', 'not_found'); + end if; + if v_link.expires_at < now() then + return jsonb_build_object('status', 'expired'); + end if; + + update app.share_links set view_count = view_count + 1 where token = p_token; + insert into app.funnel_events (event, token) values ('share_view', p_token); + + if v_link.kind = 'facility_preview' then + select jsonb_build_object( + 'status', 'ok', 'kind', v_link.kind, + 'facility', jsonb_build_object( + 'name', f.name, 'category', f.category, 'address', f.address, + 'phone', f.phone, 'is_open', f.is_open, + 'avg_rating', f.avg_rating, 'review_count', f.review_count, + 'id', f.id, + 'photo_url', bp.photo_url, + 'photo_align_y', coalesce(bp.photo_align_y, 0), + 'business_hours', bp.business_hours, + 'owner_user_id', bp.user_id, + 'owner_verified', coalesce(bp.verified, false)), + 'reviews', coalesce(( + select jsonb_agg(jsonb_build_object( + 'rating', r.rating, 'content', r.content, + 'has_incentive', r.has_incentive, + 'photo_urls', r.photos, + 'videos', r.videos) + order by r.has_media desc, r.created_at desc) + from (select rating, content, has_incentive, created_at, videos, + coalesce(array_length(photo_urls, 1), 0) > 0 + or jsonb_array_length(videos) > 0 as has_media, + (select coalesce(jsonb_agg(u), '[]'::jsonb) + from unnest(photo_urls[1:2]) u) as photos + from public.facility_reviews + where facility_id = f.id and visibility_status = 'visible' + order by coalesce(array_length(photo_urls, 1), 0) > 0 + or jsonb_array_length(videos) > 0 desc, + created_at desc + limit 3) r), '[]'::jsonb)) + into v_out + from public.facilities f + left join lateral ( + select true as verified, b.user_id, b.photo_url, b.photo_align_y, b.business_hours + from public.business_profiles b + where b.status = 'approved' + and b.matched_facility_id = any(public.facility_sibling_ids(f.id)) + order by b.reviewed_at nulls last + limit 1 + ) bp on true + where f.id = v_link.ref_id; + return coalesce(v_out, jsonb_build_object('status', 'not_found')); + end if; + + if v_link.kind = 'care_report' then + select jsonb_build_object( + 'status', 'ok', 'kind', v_link.kind, + 'report', jsonb_build_object( + 'pet_label', r.pet_label, 'photos', r.photos, 'note', r.note, + 'kind', r.kind, 'body', r.body, 'created_at', r.created_at, + 'business_name', coalesce(b.storefront_name, b.business_name))) + into v_out + from app.care_reports r + left join public.business_profiles b on b.user_id = r.business_id + where r.id = v_link.ref_id; + return coalesce(v_out, jsonb_build_object('status', 'not_found')); + end if; + + if v_link.kind = 'starter' then + select jsonb_build_object( + 'status', 'ok', 'kind', v_link.kind, + 'starter', jsonb_build_object( + 'business_name', coalesce(b.storefront_name, b.business_name))) + into v_out + from public.business_profiles b + where b.user_id = v_link.ref_id and b.status = 'approved'; + return coalesce(v_out, jsonb_build_object( + 'status', 'ok', 'kind', v_link.kind, + 'starter', jsonb_build_object('business_name', null))); + end if; + + if v_link.kind = 'post' then + select jsonb_build_object( + 'status', 'ok', 'kind', v_link.kind, + 'post', jsonb_build_object( + 'id', p.id, + 'category', p.category, 'title', p.title, 'content', p.content, + 'image_url', p.image_url, 'image_mime', p.image_mime_type, + 'image_thumb_url', p.image_thumbnail_url, + 'created_at', p.created_at, + 'author_name', case + when p.category = 'news' + then coalesce(b.storefront_name, b.business_name, pr.nickname) + else pr.nickname end)) + into v_out + from public.posts p + left join public.public_profiles pr on pr.id = p.user_id + left join public.business_profiles b + on p.category = 'news' and b.user_id = p.user_id + where p.id = v_link.ref_id and p.visibility_status = 'visible'; + return coalesce(v_out, jsonb_build_object('status', 'not_found')); + end if; + + return jsonb_build_object('status', 'ok', 'kind', v_link.kind); +end; +$function$; + +commit; From 178015ea42c959bba4ea818c9a8d39e4449ee277 Mon Sep 17 00:00:00 2001 From: seizeh Date: Mon, 27 Jul 2026 16:47:02 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EB=A7=A4=EC=9E=A5=20QR=20=EC=9D=80?= =?UTF-8?q?=20=EA=B7=B8=20=EB=A7=A4=EC=9E=A5=EC=9D=98=20=ED=9B=84=EA=B8=B0?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1=20=ED=99=94=EB=A9=B4=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?302?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 51cefc5 의 사람/크롤러 분기 위에 facility_preview 분기를 얹는다. 손님이 매장에서 QR 을 찍는 목적은 후기다. 미리보기를 한 번 거치게 하면 그만큼 이탈하므로 바로 작성 화면을 연다(작성 화면 상단에 매장 이름이 나온다). 대상이 업체 계정이 아니라 **시설(facility)** 인 게 중요하다. QR 을 나눠줄 매장 대부분은 아직 업체 인증 전이라 프로필이 없지만, 시설 행은 공공데이터로 이미 존재하고(전국 24,552곳) 후기도 시설에 달린다. 나중에 그 매장이 인증하면 business_profiles.matched_facility_id 로 같은 시설에 붙는다 — QR 을 먼저 뿌려도 주인이 나중에 나타나는 순서가 성립한다. 크롤러에게는 게시글과 같은 규칙으로 서버 렌더링을 그대로 준다(링크 미리보기 유지). 프로덕션 확인: 사람 UA → 302 /r/, facebookexternalhit → og:title "포메이트 테스트샵" / og:description "★4.2 · 후기 5개 · 미용" 정상. Co-Authored-By: Claude Opus 5 (1M context) --- supabase/functions/share-view/index.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/supabase/functions/share-view/index.ts b/supabase/functions/share-view/index.ts index c6ec30d..1ade64e 100644 --- a/supabase/functions/share-view/index.ts +++ b/supabase/functions/share-view/index.ts @@ -407,6 +407,28 @@ Deno.serve(async (req) => { } const fac = data.facility ?? {}; + + // ── 매장 QR → 그 매장의 후기 작성 화면으로 직행 (0029) ────────────────────── + // 손님이 매장에서 QR 을 찍는 목적은 후기다. 미리보기를 한 번 거치게 하면 그만큼 + // 이탈하므로 바로 작성 화면을 연다(작성 화면 상단에 매장 이름이 나온다). + // + // 대상이 업체 계정이 아니라 **시설(facility)** 인 게 중요하다. QR 을 나눠줄 매장 + // 대부분은 아직 업체 인증 전이라 프로필이 없지만, 시설 행은 공공데이터로 이미 + // 존재하고 후기도 시설에 달린다. 나중에 그 매장이 인증하면 같은 시설에 붙는다. + // + // 게시글 공유와 같은 규칙 — 크롤러는 아래 서버 렌더링을 받아야 링크 미리보기가 산다. + const facilityId = fac.id ? String(fac.id) : null; + if (WEB_APP_URL && facilityId && !isCrawler(ua)) { + return new Response(null, { + status: 302, + headers: { + location: `${WEB_APP_URL}/r/${facilityId}`, + // 동작이 바뀔 수 있으므로 브라우저가 리다이렉트를 캐시하지 않게 한다. + "cache-control": "no-store", + }, + }); + } + const catLabel = CATEGORY_LABELS[String(fac.category)] ?? String(fac.category ?? ""); const rating = Number(fac.avg_rating ?? 0); const reviewCount = Number(fac.review_count ?? 0); From 624dd66ae1c31df3f063cbdf44eecc1bfe04085f Mon Sep 17 00:00:00 2001 From: seizeh Date: Mon, 27 Jul 2026 16:48:22 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EB=B9=84=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=EB=8F=84=20=EC=A7=80=EB=8F=84=EC=9D=98=20=EC=8B=9C?= =?UTF-8?q?=EC=84=A4=EC=9D=84=20=EB=B3=B8=EB=8B=A4=20=E2=80=94=20=EC=8B=9C?= =?UTF-8?q?=EC=84=A4=20=EC=A1=B0=ED=9A=8C=20RPC=20anon=20=EA=B0=9C?= =?UTF-8?q?=EB=B0=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 20260718065841 하드닝이 이 함수들에서 anon 실행권한을 회수하며 근거를 이렇게 적었다 — "앱은 비로그인 상태에서 check_username_available 만 호출하므로 그 외 anon 불필요". **그 전제가 0029 로 깨졌다.** 매장 QR 로 들어온 손님은 로그인 전에 매장을 찾고 후기를 읽고 남기기까지 한다. 특히 지도는 게스트 모드에서 열리는데 facilities_within 이 42501 로 막혀, 마커가 하나도 없이 "주변 시설을 불러오지 못했어요" 라는 **원인과 무관한 오류**만 떴다 (실제 사용자 신고로 발견). 마커만 열면 마커를 눌렀을 때 상세·후기에서 같은 오류를 다시 만나므로 읽기 전용 4종을 함께 연다. facilities_within · facility_all_categories · facility_reviews_of · facility_review_by_id 안전성: 전부 읽기 전용이고 드러나는 데이터는 이미 anon 에 공개돼 있다. facilities 는 RLS `using (true)` + anon SELECT, 후기는 visibility_status='visible' 만 반환, owner_user_id 는 public_profiles.business_facility_id 로 이미 조회 가능하다. 같은 데이터를 RPC 로도 읽게 하는 것뿐이다. 열지 않은 것(의도적): ensure_naver_facility 는 행을 만드는 쓰기다(게스트가 후기를 쓸 땐 간이 인증으로 이미 authenticated 토큰을 들고 있어 열 필요가 없다). posts_by_region·feed_region_codes 는 지도의 게시글 레이어로 별개 관심사다. ⚠️ Supabase 린터의 anon_security_definer_function_executable 경고가 함수 수만큼 다시 뜬다. 위 근거대로 의도된 것이니 advisor 정리 때 도로 잠그지 말 것. 스키마 스냅샷은 20260727100000·20260727170000 두 마이그레이션을 함께 반영한다 (안 하면 CI 가 구스키마로 돈다). Co-Authored-By: Claude Opus 5 (1M context) --- .../20260727170000_guest_facility_read.sql | 48 ++++ supabase/schema/schema.sql | 229 ++++++++++++++++-- 2 files changed, 259 insertions(+), 18 deletions(-) create mode 100644 supabase/migrations/20260727170000_guest_facility_read.sql diff --git a/supabase/migrations/20260727170000_guest_facility_read.sql b/supabase/migrations/20260727170000_guest_facility_read.sql new file mode 100644 index 0000000..b2be3d5 --- /dev/null +++ b/supabase/migrations/20260727170000_guest_facility_read.sql @@ -0,0 +1,48 @@ +-- 시설 조회 RPC 를 비로그인에 개방 (0029 후속) +-- +-- 배경: 20260718065841_security_advisor_hardening.sql 이 이 함수들에서 anon 실행권한을 +-- 회수하면서 근거를 이렇게 적었다 — +-- "앱은 비로그인 상태에서 check_username_available 만 호출하므로 그 외 anon 불필요" +-- +-- **그 전제가 0029 로 깨졌다.** 매장 QR 로 들어온 손님은 로그인 전에 매장을 찾고 +-- 후기를 읽고 남기기까지 한다. 특히 지도는 게스트 모드(MainScreen(isGuest: true))에서 +-- 열리는데 facilities_within 이 42501 로 막혀, 마커가 하나도 없이 "주변 시설을 +-- 불러오지 못했어요" 라는 **원인과 무관한 오류**만 뜨고 있었다(실제 사용자 신고). +-- +-- 안전성: 아래는 전부 **읽기 전용**이고, 드러나는 데이터는 이미 anon 에 공개돼 있다. +-- · facilities 테이블: RLS `using (true)` + anon SELECT +-- · facility_reviews: 공개 후기(visibility_status='visible' 만 반환) +-- · owner_user_id: public_profiles.business_facility_id 로 이미 anon 조회 가능 +-- 즉 새로 노출되는 정보 없이 "같은 데이터를 RPC 로도 읽게" 하는 것뿐이다. +-- +-- 열지 않는 것(의도적): +-- · ensure_naver_facility — 행을 만드는 **쓰기**다. 게스트가 후기를 쓸 때는 +-- 간이 인증으로 이미 authenticated 토큰을 들고 있으므로 열 필요가 없다. +-- · posts_by_region / feed_region_codes / dong_centroid_seeds — 지도의 게시글 +-- 레이어. 시설과 별개 관심사이고 이번 요청 범위가 아니다(게스트가 그 레이어를 +-- 켜면 여전히 로그인이 필요하다). +-- · facilities_within 의 좌표 인자는 클라이언트가 보는 지도 중심일 뿐이라 +-- 서버가 위치를 수집하지 않는다 — 웹의 위치 수집 차단 정책과 무관하다. +-- +-- ⚠ Supabase 린터의 anon_security_definer_function_executable 경고가 이 함수 수만큼 +-- 다시 뜬다. 위 근거대로 **의도된 것**이니 advisor 정리 때 도로 잠그지 말 것. + +begin; + +grant execute on function + public.facilities_within(double precision, double precision, integer, facility_category[]) + to anon; + +grant execute on function + public.facility_all_categories(uuid) + to anon; + +grant execute on function + public.facility_reviews_of(uuid, integer, integer) + to anon; + +grant execute on function + public.facility_review_by_id(uuid) + to anon; + +commit; diff --git a/supabase/schema/schema.sql b/supabase/schema/schema.sql index 7a86a34..f400ffb 100644 --- a/supabase/schema/schema.sql +++ b/supabase/schema/schema.sql @@ -482,6 +482,32 @@ CREATE FUNCTION app.mark_push_skipped(p_notification_id uuid, p_reason text DEFA $$; +-- +-- Name: mask_phone(text); Type: FUNCTION; Schema: app; Owner: - +-- + +CREATE FUNCTION app.mask_phone(p_phone text) RETURNS text + LANGUAGE sql IMMUTABLE + SET search_path TO '' + AS $$ + select case + when d is null or length(d) < 4 then '***-****-****' + when length(d) >= 11 then + '***-' || substr(d, 4, 1) || '***-**' || right(d, 2) + -- 10자리 등 비표준 번호는 끝 2자리만 — 자리수를 억지로 맞추지 않는다. + else '***-****-**' || right(d, 2) + end + from (select regexp_replace(coalesce(p_phone, ''), '\D', '', 'g') as d) t; +$$; + + +-- +-- Name: FUNCTION mask_phone(p_phone text); Type: COMMENT; Schema: app; Owner: - +-- + +COMMENT ON FUNCTION app.mask_phone(p_phone text) IS '간이 회원 표시명 — 010 전체 마스킹 + 가운데 앞 1자리 + 끝 2자리(***-1***-**78).'; + + -- -- Name: norm_biz_text(text); Type: FUNCTION; Schema: app; Owner: - -- @@ -2301,6 +2327,30 @@ CREATE FUNCTION app.uid() RETURNS uuid $$; +-- +-- Name: uid_lite(); Type: FUNCTION; Schema: app; Owner: - +-- + +CREATE FUNCTION app.uid_lite() RETURNS uuid + LANGUAGE sql STABLE SECURITY DEFINER + SET search_path TO '' + AS $$ + select u.id + from public.users u + where u.id = nullif((nullif(current_setting('request.jwt.claims', true),'')::jsonb)->>'sub','')::uuid + and u.status in ('active', 'lite') + and u.token_version = coalesce( + ((nullif(current_setting('request.jwt.claims', true),'')::jsonb)->>'tv')::int, 0) +$$; + + +-- +-- Name: FUNCTION uid_lite(); Type: COMMENT; Schema: app; Owner: - +-- + +COMMENT ON FUNCTION app.uid_lite() IS '후기 작성 전용 uid — active + lite 허용. 이 함수를 다른 기능에 쓰지 말 것(간이 회원은 후기 외 모든 기능에서 비회원이어야 한다).'; + + -- -- Name: _push_pref_allows(uuid, text); Type: FUNCTION; Schema: public; Owner: - -- @@ -2335,12 +2385,31 @@ CREATE FUNCTION public.add_facility_review(p_facility uuid, p_rating smallint, p SET search_path TO '' AS $$ declare - v_uid uuid := app.uid(); - v_id uuid; - v jsonb; + v_uid uuid := app.uid_lite(); + v_id uuid; + v jsonb; + v_status text; + v_phone text; begin if v_uid is null then raise exception 'auth required'; end if; if p_rating < 1 or p_rating > 5 then raise exception 'rating 1..5'; end if; + + select status, phone into v_status, v_phone + from public.users where id = v_uid; + + -- 간이 회원은 후기 한 건마다 전화 인증을 새로 받는다(세션을 들고 다니지 않는다). + if v_status = 'lite' then + if not exists ( + select 1 from public.phone_verifications + where phone = v_phone + and purpose = 'review' + and is_used = true + and created_at > now() - interval '15 minutes' + ) then + raise exception 'reverify_required' using errcode = 'P0001'; + end if; + end if; + if exists ( select 1 from public.business_profiles bp where bp.user_id = v_uid @@ -4483,19 +4552,21 @@ CREATE FUNCTION public.facility_review_by_id(p_review uuid) RETURNS TABLE(id uui LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO '' AS $$ - select r.id, r.user_id, pr.nickname, r.rating, r.content, r.photo_urls, r.created_at, - (r.user_id = app.uid()) as is_mine, r.visit_no, r.has_incentive, r.videos + select r.id, r.user_id, + case when au.status = 'lite' then app.mask_phone(au.phone) + else pr.nickname end, + r.rating, r.content, r.photo_urls, r.created_at, + (r.user_id = app.uid_lite()) as is_mine, r.visit_no, r.has_incentive, r.videos from ( select fr.*, row_number() over ( partition by fr.user_id order by fr.created_at )::int as visit_no from public.facility_reviews fr - where fr.facility_id = any(public.facility_sibling_ids( - (select facility_id from public.facility_reviews where id = p_review))) - and fr.visibility_status = 'visible' + where fr.visibility_status = 'visible' ) r left join public.public_profiles pr on pr.id = r.user_id + left join public.users au on au.id = r.user_id where r.id = p_review; $$; @@ -4508,8 +4579,11 @@ CREATE FUNCTION public.facility_reviews_of(p_facility uuid, p_limit integer DEFA LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO '' AS $$ - select r.id, r.user_id, pr.nickname, r.rating, r.content, r.photo_urls, r.created_at, - (r.user_id = app.uid()) as is_mine, r.visit_no, r.has_incentive, r.videos + select r.id, r.user_id, + case when au.status = 'lite' then app.mask_phone(au.phone) + else pr.nickname end, + r.rating, r.content, r.photo_urls, r.created_at, + (r.user_id = app.uid_lite()) as is_mine, r.visit_no, r.has_incentive, r.videos from ( select fr.*, row_number() over ( @@ -4520,6 +4594,7 @@ CREATE FUNCTION public.facility_reviews_of(p_facility uuid, p_limit integer DEFA and fr.visibility_status = 'visible' ) r left join public.public_profiles pr on pr.id = r.user_id + left join public.users au on au.id = r.user_id order by r.created_at desc limit least(p_limit, 50) offset p_offset; $$; @@ -5508,9 +5583,11 @@ begin 'name', f.name, 'category', f.category, 'address', f.address, 'phone', f.phone, 'is_open', f.is_open, 'avg_rating', f.avg_rating, 'review_count', f.review_count, + 'id', f.id, 'photo_url', bp.photo_url, 'photo_align_y', coalesce(bp.photo_align_y, 0), 'business_hours', bp.business_hours, + 'owner_user_id', bp.user_id, 'owner_verified', coalesce(bp.verified, false)), 'reviews', coalesce(( select jsonb_agg(jsonb_build_object( @@ -5533,7 +5610,7 @@ begin into v_out from public.facilities f left join lateral ( - select true as verified, b.photo_url, b.photo_align_y, b.business_hours + select true as verified, b.user_id, b.photo_url, b.photo_align_y, b.business_hours from public.business_profiles b where b.status = 'approved' and b.matched_facility_id = any(public.facility_sibling_ids(f.id)) @@ -5598,6 +5675,82 @@ end; $$; +-- +-- Name: signup_lite_user(text, boolean); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.signup_lite_user(p_phone text, p_privacy_consent boolean DEFAULT false) RETURNS uuid + LANGUAGE plpgsql SECURITY DEFINER + SET search_path TO '' + AS $$ +declare + v_id uuid; + v_status text; + v_hex text; +begin + -- 개인정보(전화번호) 수집·이용 동의는 필수. 클라이언트 체크박스만 믿지 않는다. + if coalesce(p_privacy_consent, false) is not true then + raise exception 'privacy_consent_required' using errcode = 'P0001'; + end if; + + -- 후기 목적 전화 인증 확인(30분 이내). purpose 를 'signup' 과 분리하는 이유: + -- send-phone-code 가 signup 목적은 "이미 가입된 번호"에 발송을 거부하는데, + -- 간이 후기는 정식 회원이 쓸 수도 있어 그 차단에 걸리면 안 된다. + if not exists ( + select 1 from public.phone_verifications + where phone = p_phone + and purpose = 'review' + and is_used = true + and created_at > now() - interval '30 minutes' + ) then + raise exception 'phone_not_verified' using errcode = 'P0001'; + end if; + + select id, status into v_id, v_status + from public.users where phone = p_phone; + + if found then + -- 정지·탈퇴 계정이 간이 경로로 되살아나면 안 된다. + if v_status in ('suspended', 'deleted') then + raise exception 'account_unavailable' using errcode = 'P0001'; + end if; + return v_id; + end if; + + -- username/nickname 은 NOT NULL + 유니크라 값이 필요하지만 사용자에게는 보이지 + -- 않는다(표시는 app.mask_phone). password_hash 는 argon2id/bcrypt 어느 쪽으로도 + -- 검증에 성공할 수 없는 sentinel — 비밀번호 로그인 경로를 원천 차단한다. + -- gen_random_uuid() 는 pg_catalog 라 search_path='' 에서도 그냥 보인다. + -- (pgcrypto 의 gen_random_bytes 는 extensions 스키마라 여기서 안 보인다.) + v_hex := substr(replace(gen_random_uuid()::text, '-', ''), 1, 12); + + insert into public.users ( + username, password_hash, nickname, user_type, phone, phone_verified, + status, terms_agreed_at + ) values ( + 'lite_' || v_hex, + '!', + 'lite_' || v_hex, + 'no_pet', + p_phone, + true, + 'lite', + now() + ) + returning id into v_id; + + return v_id; +end; +$$; + + +-- +-- Name: FUNCTION signup_lite_user(p_phone text, p_privacy_consent boolean); Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON FUNCTION public.signup_lite_user(p_phone text, p_privacy_consent boolean) IS '간이 회원 생성/조회 — 엣지 signup-lite 전용(service_role). 같은 번호는 항상 같은 계정.'; + + -- -- Name: signup_user(text, text, text, text, text, boolean); Type: FUNCTION; Schema: public; Owner: - -- @@ -5607,7 +5760,8 @@ CREATE FUNCTION public.signup_user(p_username text, p_password_hash text, p_nick SET search_path TO '' AS $$ declare - v_id uuid; + v_id uuid; + v_status text; begin -- 1) 전화 인증 완료 확인 (signup 목적, 사용처리됨, 30분 이내) if not exists ( @@ -5627,11 +5781,30 @@ begin if exists (select 1 from public.users where lower(nickname) = lower(p_nickname)) then raise exception 'nickname_taken' using errcode = 'P0001'; end if; - if exists (select 1 from public.users where phone = p_phone) then - raise exception 'phone_taken' using errcode = 'P0001'; - end if; - -- 3) INSERT (해시는 엣지에서 argon2id 로 생성, 필수 약관 동의 시각 기록) + -- 3) 같은 번호가 이미 있으면 — lite 면 승격, 그 외엔 기존대로 거부. + select id, status into v_id, v_status + from public.users where phone = p_phone; + + if found then + if v_status <> 'lite' then + raise exception 'phone_taken' using errcode = 'P0001'; + end if; + update public.users set + username = p_username, + password_hash = p_password_hash, + nickname = p_nickname, + user_type = p_user_type, + status = 'active', + terms_agreed_at = now(), + marketing_opt_in = coalesce(p_marketing, false), + marketing_opt_in_at = case when coalesce(p_marketing, false) then now() else null end, + updated_at = now() + where id = v_id; + return v_id; + end if; + + -- 4) 신규 INSERT (해시는 엣지에서 argon2id 로 생성, 필수 약관 동의 시각 기록) insert into public.users ( username, password_hash, nickname, user_type, phone, phone_verified, terms_agreed_at, marketing_opt_in, marketing_opt_in_at @@ -6927,7 +7100,7 @@ CREATE TABLE public.phone_verifications ( expires_at timestamp with time zone NOT NULL, is_used boolean DEFAULT false NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT phone_verifications_purpose_check CHECK (((purpose)::text = ANY ((ARRAY['signup'::character varying, 'password_reset'::character varying])::text[]))) + CONSTRAINT phone_verifications_purpose_check CHECK (((purpose)::text = ANY ((ARRAY['signup'::character varying, 'password_reset'::character varying, 'review'::character varying])::text[]))) ); @@ -7172,7 +7345,7 @@ CREATE TABLE public.users ( active_mode character varying DEFAULT 'personal'::character varying NOT NULL, CONSTRAINT users_active_mode_check CHECK (((active_mode)::text = ANY ((ARRAY['personal'::character varying, 'business'::character varying])::text[]))), CONSTRAINT users_activity_radius_chk CHECK (((activity_radius_m IS NULL) OR ((activity_radius_m >= 5000) AND (activity_radius_m <= 15000)))), - CONSTRAINT users_status_check CHECK (((status)::text = ANY ((ARRAY['active'::character varying, 'inactive'::character varying, 'suspended'::character varying, 'deleted'::character varying])::text[]))), + CONSTRAINT users_status_check CHECK (((status)::text = ANY ((ARRAY['active'::character varying, 'inactive'::character varying, 'suspended'::character varying, 'deleted'::character varying, 'lite'::character varying])::text[]))), CONSTRAINT users_unread_chat_count_nonneg CHECK ((unread_chat_count >= 0)), CONSTRAINT users_unread_notification_count_nonneg CHECK ((unread_notification_count >= 0)), CONSTRAINT users_user_type_check CHECK (((user_type)::text = ANY ((ARRAY['pet_owner'::character varying, 'no_pet'::character varying, 'business'::character varying, 'admin'::character varying])::text[]))), @@ -7187,6 +7360,13 @@ CREATE TABLE public.users ( COMMENT ON TABLE public.users IS '사용자(커스텀 인증). password_hash 노출 금지 → 외부는 public_profiles 뷰 조회'; +-- +-- Name: COLUMN users.status; Type: COMMENT; Schema: public; Owner: - +-- + +COMMENT ON COLUMN public.users.status IS 'active=정식 회원 · lite=후기 전용 간이 회원(비회원 취급, app.uid() 에서 제외) · inactive=휴면 · suspended=정지 · deleted=탈퇴'; + + -- -- Name: COLUMN users.unread_notification_count; Type: COMMENT; Schema: public; Owner: - -- @@ -11152,6 +11332,7 @@ GRANT ALL ON FUNCTION public.ensure_naver_facility(p_name text, p_address text, REVOKE ALL ON FUNCTION public.facilities_search(p_query text, p_lng double precision, p_lat double precision) FROM PUBLIC; GRANT ALL ON FUNCTION public.facilities_search(p_query text, p_lng double precision, p_lat double precision) TO authenticated; GRANT ALL ON FUNCTION public.facilities_search(p_query text, p_lng double precision, p_lat double precision) TO service_role; +GRANT ALL ON FUNCTION public.facilities_search(p_query text, p_lng double precision, p_lat double precision) TO anon; -- @@ -11161,6 +11342,7 @@ GRANT ALL ON FUNCTION public.facilities_search(p_query text, p_lng double precis REVOKE ALL ON FUNCTION public.facilities_within(p_lng double precision, p_lat double precision, p_radius_m integer, p_categories public.facility_category[]) FROM PUBLIC; GRANT ALL ON FUNCTION public.facilities_within(p_lng double precision, p_lat double precision, p_radius_m integer, p_categories public.facility_category[]) TO authenticated; GRANT ALL ON FUNCTION public.facilities_within(p_lng double precision, p_lat double precision, p_radius_m integer, p_categories public.facility_category[]) TO service_role; +GRANT ALL ON FUNCTION public.facilities_within(p_lng double precision, p_lat double precision, p_radius_m integer, p_categories public.facility_category[]) TO anon; -- @@ -11170,6 +11352,7 @@ GRANT ALL ON FUNCTION public.facilities_within(p_lng double precision, p_lat dou REVOKE ALL ON FUNCTION public.facility_all_categories(p_id uuid) FROM PUBLIC; GRANT ALL ON FUNCTION public.facility_all_categories(p_id uuid) TO authenticated; GRANT ALL ON FUNCTION public.facility_all_categories(p_id uuid) TO service_role; +GRANT ALL ON FUNCTION public.facility_all_categories(p_id uuid) TO anon; -- @@ -11179,6 +11362,7 @@ GRANT ALL ON FUNCTION public.facility_all_categories(p_id uuid) TO service_role; REVOKE ALL ON FUNCTION public.facility_review_by_id(p_review uuid) FROM PUBLIC; GRANT ALL ON FUNCTION public.facility_review_by_id(p_review uuid) TO authenticated; GRANT ALL ON FUNCTION public.facility_review_by_id(p_review uuid) TO service_role; +GRANT ALL ON FUNCTION public.facility_review_by_id(p_review uuid) TO anon; -- @@ -11188,6 +11372,7 @@ GRANT ALL ON FUNCTION public.facility_review_by_id(p_review uuid) TO service_rol REVOKE ALL ON FUNCTION public.facility_reviews_of(p_facility uuid, p_limit integer, p_offset integer) FROM PUBLIC; GRANT ALL ON FUNCTION public.facility_reviews_of(p_facility uuid, p_limit integer, p_offset integer) TO authenticated; GRANT ALL ON FUNCTION public.facility_reviews_of(p_facility uuid, p_limit integer, p_offset integer) TO service_role; +GRANT ALL ON FUNCTION public.facility_reviews_of(p_facility uuid, p_limit integer, p_offset integer) TO anon; -- @@ -11505,6 +11690,14 @@ REVOKE ALL ON FUNCTION public.share_view_load(p_token character varying) FROM PU GRANT ALL ON FUNCTION public.share_view_load(p_token character varying) TO service_role; +-- +-- Name: FUNCTION signup_lite_user(p_phone text, p_privacy_consent boolean); Type: ACL; Schema: public; Owner: - +-- + +REVOKE ALL ON FUNCTION public.signup_lite_user(p_phone text, p_privacy_consent boolean) FROM PUBLIC; +GRANT ALL ON FUNCTION public.signup_lite_user(p_phone text, p_privacy_consent boolean) TO service_role; + + -- -- Name: FUNCTION signup_user(p_username text, p_password_hash text, p_nickname text, p_user_type text, p_phone text, p_marketing boolean); Type: ACL; Schema: public; Owner: - -- From 85102c8e5329679fb3cdc8480ad9bec1fc226a7f Mon Sep 17 00:00:00 2001 From: seizeh Date: Mon, 27 Jul 2026 17:12:08 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=200029=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20+=20signup-lite=20API=20=EB=A0=88=ED=8D=BC?= =?UTF-8?q?=EB=9F=B0=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0029.md — 간이 회원 설계. 왜 이렇게 됐는지가 코드만 봐서는 안 보이는 결정들을 남긴다. · status='lite' 를 고른 이유 — app.uid() 가 active 를 요구하므로 기본이 차단이 된다. 화이트리스트 방식이었다면 새 RPC 마다 빠뜨릴 위험이 생긴다. · 승격이 선택이 아닌 이유 — users_phone_uq 때문에 승격을 넣지 않으면 간이로 후기를 쓴 사람이 정식 가입을 아예 못 한다. · 마스크를 nickname 컬럼에 넣으면 안 되는 이유 — 조합 1,000가지 대 유니크 제약. · QR 대상이 업체 계정이 아니라 시설인 이유 — 시설이 곧 '주인 없는 프로필'이고, 인증 전 매장에 뿌린 QR 도 나중에 주인이 나타나는 순서가 성립한다. · business_profiles 에 주인 없는 행을 미리 만들지 말라는 경고. 0028 §배경이 적은 "스캔 → 설치 → 전화번호 인증 → 위치 동의 → 지역 인증 → 후기 작성" 퍼널이 **사실과 달랐다는 것**도 기록했다. add_facility_review 에는 지역·위치 조건이 없고 트리거도 전부 AFTER 다. 실제 마찰은 설치와 가입 둘뿐이었다. docs/supabase-api.md — signup-lite 항목 신설(verify-phone-code 를 따로 부르지 않는 이유, 토큰이 15분인 이유, 표시명 규칙이 3곳에 중복된다는 경고 포함), 전화 인증 review 목적, §3.2-1 간이 회원 흐름, signup 의 lite 승격 분기. 문서가 2026-07-02 스냅샷이라 실제 배포(21개)와 벌어져 있던 것도 드러났다. 이번에 확인한 범위만 갱신하고, 항목이 없는 4개(apply-business·check-business-no· purge-business-docs·share-view)를 표로 명시했다 — 읽지 않은 코드를 지어내지 않기 위해 용도와 설계 문서만 적었다. README — db push 금지 경고. 이력 테이블이 실제와 어긋나 이미 적용된 6개를 재실행하려 든다(이번 배포에서 확인). psql 직접 적용과 rollback 사전 검증을 적었다. Co-Authored-By: Claude Opus 5 (1M context) --- 0029.md | 232 +++++++++++++++++++++++++++++++++++++++++++ README.md | 5 + docs/supabase-api.md | 67 +++++++++++-- 3 files changed, 294 insertions(+), 10 deletions(-) create mode 100644 0029.md diff --git a/0029.md b/0029.md new file mode 100644 index 0000000..2f3f7ee --- /dev/null +++ b/0029.md @@ -0,0 +1,232 @@ +# 0029 — 간이 회원(lite): 가입 없이 후기 남기기 · 매장 QR 직행 · 비로그인 개방 + +> 상태: **구현·배포 완료**(2026-07-27). 작성·구현 같은 날. +> 범위: 회원가입 없이 전화번호 인증만으로 시설 후기를 작성하는 경로(`users.status='lite'`), +> 매장 QR 이 후기 작성 화면으로 직행하는 딥링크(`/r/`), 그 동선에 필요한 +> 비로그인(anon) 조회 개방. 업체 인증·업체 도구는 다루지 않는다(0028). +> 관련: **0028(오프라인 업체 제휴 — QR·공유 뷰어·업체 도구)**, 0022(시설 후기), +> 0021(시설 지도), 0025(업체 인증), pmlegal `07간이후기이용조건.md`(정본). + +## 배경 — 0028 이 지목한 공백이 실제보다 컸다 + +0028 §배경은 유저 쪽 퍼널을 이렇게 적었다. + +> 스캔 → 설치 → 전화번호 인증 → 위치 동의 → 지역 인증 → 후기 작성은 무거운 퍼널이다. + +이번에 프로덕션 정의를 직접 확인해보니 **그 체인은 사실과 달랐다.** `add_facility_review` +의 검사는 네 가지뿐이고(로그인 · 별점 1..5 · `own_facility` 차단 · 영상 형식) **지역·위치 +관련 조건이 한 줄도 없다.** `facility_reviews` 의 트리거 3개도 전부 AFTER(집계·회수·알림)라 +작성을 막지 않는다. 지역 인증은 처음부터 커뮤니티 전용 게이트였고 시설 후기와 분리돼 +있었다. + +즉 실제로 남아 있던 마찰은 **설치와 회원가입** 둘뿐이었다. 웹 이식으로 설치가 빠졌으니 +남는 건 회원가입 하나다. 이 문서는 그 하나를 마저 걷어낸다. + +## 설계 원칙 + +1. **인증은 게시 직전에만 청한다.** 쓰기 전에 가입을 요구하면 대부분 이탈한다. 다 쓰고 + '게시'를 누르는 순간에 번호 인증을 청하고, 취소해도 초안(별점·본문·사진)을 잃지 않는다. +2. **간이 회원은 비회원이다.** 후기 작성 외에는 아무것도 못 한다. 이용약관 전체 동의를 + 받지 않았으므로 그 이상의 권한을 주면 안 된다. +3. **기본은 차단, 예외만 연다.** 새 기능이 생겨도 간이 회원이 자동으로 접근하게 되면 안 된다. +4. **계정은 처음부터 하나다.** 간이와 정식을 별도 계정으로 만들고 나중에 합치지 않는다. + +## §1. 간이 회원 모델 — `users.status='lite'` + +`app.uid()` 는 `status='active'` 를 요구한다(0027 JWT 무상태 정지 반영). 따라서 `lite` 로 +두면 **RLS·RPC 전체에서 자동으로 차단된다** — 채팅·게시글·펫·알림 어디에도 손댈 필요가 없다. +후기 작성 하나만 `app.uid_lite()`(= `status in ('active','lite')`)로 뚫는다. + +이 방향이 핵심이다. 반대로 화이트리스트(간이 회원이 접근 가능한 것 나열)로 갔다면 새 RPC가 +추가될 때마다 빠뜨릴 위험이 생긴다. 여기서는 **기본값이 차단**이라 잊어도 안전하다. + +``` +app.uid() status='active' → 그 외 모든 기능 +app.uid_lite() status in (active, lite) → add_facility_review 에서만 사용 +``` + +⚠️ `app.uid_lite()` 를 다른 기능에 쓰면 "간이 회원 = 비회원" 전제가 무너진다. 함수 주석에 +경고를 달아뒀다. + +### 계정 필드 + +`username`·`password_hash`·`nickname`·`user_type` 이 전부 NOT NULL 이라 값은 채워야 한다. + +| 필드 | 값 | 이유 | +| --- | --- | --- | +| `username` | `lite_<12hex>` | 비노출. 사용자는 아이디를 만들지 않는다 | +| `nickname` | `lite_<12hex>` | 비노출. 표시는 §2 의 마스크가 담당 | +| `password_hash` | `'!'` | argon2id·bcrypt 어느 쪽으로도 검증 불가 → 비밀번호 로그인 원천 차단 | +| `user_type` | `no_pet` | 도메인 허용값 중 최소 권한 | +| `terms_agreed_at` | `now()` | 간이 동의(§5) 시각 | + +## §2. 승격 — 병합이 아니라 같은 행 + +`users.phone` 에 유니크 인덱스(`users_phone_uq`)가 있으므로 **같은 번호는 같은 행일 수밖에 +없다.** 그래서 "나중에 동기화"가 아니라 처음부터 하나이고, 정식 가입은 그 행에 +username/password_hash/nickname 을 채우는 **승격**이다. + +이건 선택이 아니라 필수다. 승격을 넣지 않으면 `signup_user` 가 `phone_taken` 을 던져 +**간이로 후기를 쓴 사람이 정식 가입을 아예 못 한다.** + +승격의 부수 효과가 그대로 이득이 된다. + +- `facility_reviews_of` 의 `visit_no` 는 `row_number() over (partition by user_id ...)` 라 + user_id 가 유지되면 방문 회차가 끊기지 않는다. 간이로 3번 쓰고 전환하면 4번째가 된다. +- 작성자 표시가 마스크에서 닉네임으로 자동 전환된다(§3). +- 별도 계정 생성 후 이관하는 방식이었다면 중복 계정·FK·알림 재배치가 전부 따라왔다. + +`signup_user` 의 분기: 같은 번호가 존재하고 `status='lite'` → UPDATE 승격. 그 외 → +기존대로 `phone_taken`. + +## §3. 작성자 표시명 — `***-1***-**78` + +전화번호를 그대로 노출할 수 없으므로 마스킹한다. 앞 3자리(통신사 식별번호)는 전부 가리고, +가운데 4자리 중 첫 자리와 마지막 2자리만 남긴다. 본인은 알아보되 남은 식별력은 낮다. + +**이 값을 `nickname` 컬럼에 넣으면 안 된다.** 조합이 1,000가지뿐인데 `nickname` 은 대소문자 +무시 유니크(`users_lower_nickname_uq`)라 즉시 충돌한다. `nickname` 에는 비노출 내부값을 두고, +조회 시점에 `app.mask_phone(phone)` 으로 만든다. + +적용 지점은 `facility_reviews_of` / `facility_review_by_id` 의 `author_nickname` **값 치환** +이다. 컬럼을 추가하면 `RETURNS TABLE` 이 달라져 `create or replace` 가 거부되고 drop 이 +필요해지는데, 그러면 앱 구버전이 잠시 깨진다. + +같은 규칙이 세 곳에 있으므로 **함께 고쳐야 한다** — `app.mask_phone`(DB), +`signup-lite/index.ts` 의 `maskPhone`(응답용), 앱 시트의 `_maskPreview`(입력 중 미리보기). + +## §4. 매 작성마다 재인증 + +간이 회원은 세션을 들고 다니지 않는다. 후기 1건을 쓰는 동안만 유효하고, 다음 후기를 쓰려면 +문자 인증을 다시 받는다. 이것이 어뷰징의 1차 방어이기도 하다(작성 1건당 SMS 1통의 비용). + +**서버에서 강제한다.** `add_facility_review` 가 `status='lite'` 인 호출자에 대해 최근 15분 내 +`purpose='review'` + `is_used=true` 인증 기록을 요구하고, 없으면 `reverify_required`. +클라이언트만 막으면 우회된다. + +토큰 수명도 15분으로 맞췄다(`signup-lite` 의 `LITE_TTL`). DB 쪽 창과 어긋나면 토큰은 살아 +있는데 작성이 거부되는 구간이 생긴다. + +### `purpose='review'` 를 신설한 이유 + +`signup` 목적을 재사용할 수 없다. `send-phone-code` 가 signup 목적은 **이미 가입된 번호에 +발송을 거부**하는데(`phone_taken`), 간이 후기는 정식 회원도 쓸 수 있어야 한다. 목적을 나누면 +그 차단과 무관해지고, 후기용 인증이 가입에 전용되는 것도 함께 막힌다. + +⚠️ `phone_verifications_purpose_check` 제약을 같이 고쳐야 한다. 안 고치면 INSERT 가 막힌다 +(0016 notification_type 과 같은 함정). + +## §5. 법적 정비 — 최소 동의 1건 + +"약관 동의 없이 쓴다"가 이 기능의 취지지만, 전화번호는 개인정보이고 후기는 게시물이라 +완전 무동의로는 받을 수 없다. 정식 가입의 전체 동의 대신 **필수 체크박스 하나**로 최소화했다. + +핵심은 수집 목적에 **"정식 회원 가입 시 기존 간이 후기의 계정 연결"** 을 명시한 것이다. +§2 의 승격이 개인정보 이용에 해당하므로 그 처리가 동의 항목에 적혀 있어야 한다. + +서버도 동의 없이는 거부한다(`signup_lite_user(p_privacy_consent)` → `privacy_consent_required`). +클라이언트 체크박스만 믿지 않는다. + +정본은 pmlegal `07간이후기이용조건.md`, 앱은 이를 `assets/terms/lite_review_terms.md` 로 +번들해 동의 화면에서 띄운다. **정본과 배포본이 갈리지 않게 함께 갱신할 것.** + +## §6. 매장 QR → 후기 작성 직행 + +QR(`kind='facility_preview'`)을 사람이 열면 `${WEB_APP_URL}/r/` 로 302 한다. +미리보기를 한 번 거치게 하면 그만큼 이탈하므로 바로 작성 화면을 연다(상단에 매장 이름이 나온다). +크롤러에게는 서버 렌더링을 그대로 줘서 링크 미리보기를 지킨다(게시글 공유와 같은 규칙). + +### 대상이 업체 계정이 아니라 시설인 이유 + +**시설(facility)이 곧 '주인 없는 프로필'이다.** QR 을 나눠줄 매장 대부분은 아직 업체 인증 +전이라 `business_profiles` 행이 없지만, `facilities` 행은 공공데이터로 이미 존재한다 +(전국 24,552곳, 동탄 190곳 — 미용 88·위탁호텔 48·동물병원 30·분양 24). 후기도 시설에 달린다. + +나중에 그 매장이 업체 인증을 마치면 `business_profiles.matched_facility_id` 로 같은 시설에 +붙어 대표사진·영업시간 수정 권한을 갖는다. 즉 **QR 을 먼저 뿌려도 주인이 나중에 나타나는 +순서가 자연스럽게 성립한다.** + +업체 계정을 대상으로 잡았다면 인증 전 매장의 QR 은 아무 데도 갈 곳이 없었다. + +⚠️ 미인증 매장을 위해 `business_profiles` 에 주인 없는 행을 미리 만들지 말 것. 그 테이블은 +`users` 와 1:1 이고 승인제라 가짜 사용자 계정이 필요해지고(전화번호 유니크·인증까지), 진짜 +주인이 인증할 때 중복 정리가 따라온다. + +### 딥링크 3종 + +| 경로 | 대상 | 쓰는 곳 | +| --- | --- | --- | +| `/p/` | 게시글 상세 | 게시글 공유 | +| `/u/` | 업체 프로필 | (예비 — QR 은 쓰지 않음) | +| `/r/` | 후기 작성 | **매장 QR** | + +## §7. 비로그인 개방 범위 + +`20260718065841` 하드닝이 시설 조회 RPC 들에서 anon 실행권한을 회수하며 근거를 이렇게 적었다. + +> 앱은 비로그인 상태에서 check_username_available 만 호출하므로 그 외 anon 불필요 + +**이 문서가 그 전제를 깬다.** 간이 회원은 로그인 전에 매장을 찾고 후기를 읽고 남기기까지 한다. +실제로 지도가 게스트 모드에서 `facilities_within` 42501 에 막혀 마커가 하나도 없이 "주변 +시설을 불러오지 못했어요" 라는 원인과 무관한 오류만 뜨고 있었다(실사용 신고로 발견). + +| 함수 | anon | 비고 | +| --- | --- | --- | +| `facilities_within` | ✅ 개방 | 지도 마커 | +| `facilities_search` | ✅ 개방 | 상호 검색(검색 탭·지도 검색창) | +| `facility_all_categories` | ✅ 개방 | 상세의 겸업 업종 | +| `facility_reviews_of` | ✅ 개방 | 후기 목록 | +| `facility_review_by_id` | ✅ 개방 | 후기 상세 | +| `ensure_naver_facility` | ❌ 유지 | **쓰기**. 게스트도 후기 작성 시엔 authenticated 토큰을 든다 | +| `posts_by_region`·`feed_region_codes` | ❌ 유지 | 지도의 게시글 레이어 — 별개 관심사 | + +안전성: 개방한 것은 전부 읽기 전용이고 드러나는 데이터는 이미 anon 에 공개돼 있다. +`facilities` 는 RLS `using (true)` + anon SELECT, 후기는 `visibility_status='visible'` 만 +반환, `owner_user_id` 는 `public_profiles.business_facility_id` 로 이미 조회 가능하다. +같은 데이터를 RPC 로도 읽게 하는 것뿐이다. + +⚠️ Supabase 린터의 `anon_security_definer_function_executable` 경고가 개방한 수만큼 다시 +뜬다. **의도된 것이니 advisor 정리 때 도로 잠그지 말 것.** + +## §8. 어뷰징 — v1 은 방어 없이 출시 + +번호만으로 즉시 후기가 되면 업체가 알바 번호로 자기 후기를 쌓기 쉬워진다(`own_facility` +차단은 `business_profiles` 에 연결된 계정만 막는다). 초기 후기 확보 속도를 우선해 +**제한 없이 출시**하고, 실제 어뷰징이 관측되면 대응하기로 했다. + +이미 걸려 있는 마찰: 작성 1건당 SMS 1통 + 번호당 10회/10분·IP 30회/10분 레이트리밋. + +후속 후보(관측 후 판단): 시설당 하루 작성 수 제한(DB 트리거), 간이 후기 배지 표시, +간이 후기 사진 필수. + +## §9. 구현 현황 + +| 범위 | 산출물 | +| --- | --- | +| DB | `20260727100000_lite_reviewer.sql`, `20260727170000_guest_facility_read.sql` | +| 엣지 | `signup-lite`(신규), `send-phone-code`·`verify-phone-code`(review 목적), `share-view`(302) | +| 앱 | `lite_review_auth_sheet`, `session`(메모리 전용 토큰), `facility_review_screen`(초안 보존), `web_link`(딥링크 3종), `social_repository`(게스트 검색) | +| 법무 | pmlegal `07간이후기이용조건.md` | + +### 검증 (프로덕션, 전부 rollback 트랜잭션) + +마스크 형식 · 동의 없이 거부 · 미인증 거부 · 같은 번호 재사용 시 동일 계정 · `app.uid()` +차단 대 `uid_lite()` 허용 · 후기 작성 · `visit_no` 1→2 · 인증 만료 시 `reverify_required` · +승격 후 같은 계정 유지 · 승격 후 표시명 전환과 회차 유지 · 정식 계정 중복 가입 거부 — 13개 통과. + +배포 후 실물: 사람 UA → `302 /r/`, `facebookexternalhit` → OG 태그 정상, +`signup-lite` 검증 경로 400 2종, anon 으로 시설 287건·후기 5건 조회. + +### 미검증 + +`send-phone-code` 의 `purpose='review'` 는 호출 시 실제 SMS 가 발송되어 테스트하지 않았다. +목적 화이트리스트 문자열 추가와 DB CHECK 뿐이고 CHECK 는 확인했다. + +## §10. 운영 주의 + +- **`supabase db push` 금지.** 마이그레이션 이력 테이블이 실제 적용 상태와 어긋나 있어 + 이미 적용된 6개를 재실행하려 든다. psql 직접 적용이 이 저장소의 실제 절차다. +- **`users` INSERT 트리거가 간이 계정에도 고객센터 채팅방·알림설정을 만든다.** 무해하고 + 승격 시 재사용되지만, 간이 계정이 대량으로 쌓이면 함께 쌓인다. 승격 경로를 단순하게 + 유지하려고 그대로 뒀다 — 정리가 필요해지면 그때 판단한다. +- QR 을 뿌리기 전에 매장별 `facility_preview` 링크를 관리자 화면에서 발급해야 한다. diff --git a/README.md b/README.md index cc46ecf..80dd7c6 100644 --- a/README.md +++ b/README.md @@ -76,12 +76,17 @@ supabase/ ```bash # 마이그레이션 +# ⚠️ db push 는 쓰지 말 것 — 이력 테이블이 실제 적용 상태와 어긋나 있어 이미 적용된 +# 마이그레이션을 재실행하려 든다. psql 로 파일을 직접 적용하는 것이 현재 절차다. +# psql "$SUPABASE_DB_URL" -X -q -v ON_ERROR_STOP=1 -f supabase/migrations/<파일>.sql +# 적용 전 검증은 같은 파일의 commit; 을 rollback; 으로 바꿔 한 번 돌려본다. supabase db push # 함수 supabase functions deploy send-phone-code --no-verify-jwt supabase functions deploy verify-phone-code --no-verify-jwt supabase functions deploy signup --no-verify-jwt +supabase functions deploy signup-lite --no-verify-jwt supabase functions deploy login --no-verify-jwt ``` diff --git a/docs/supabase-api.md b/docs/supabase-api.md index f4c98cf..f36f620 100644 --- a/docs/supabase-api.md +++ b/docs/supabase-api.md @@ -1,6 +1,7 @@ # PawMate Supabase — API(Edge Functions) 문서 - **조사일**: 2026-07-02 — 로컬 소스(`supabase/functions/`) + 원격 배포 상태 대조 +- **부분 갱신**: 2026-07-27 — `signup-lite` 신설(0029), 전화 인증 `review` 목적 추가, 배포 현황 주석. 그 외 항목은 2026-07-02 시점 그대로다 - **짝 문서**: [supabase-db.md](supabase-db.md) — DB 스키마/로직 레퍼런스 - 소스 위치: `/Users/seize_h/StudioProjects/pmdb/supabase/functions/` @@ -13,10 +14,11 @@ | 슬러그 | 용도 | 인증 | verify_jwt(배포) | 배포 상태 | |---|---|---|---|---| -| `send-phone-code` | 전화 인증번호(6자리) 발급 + Solapi SMS 발송 | 없음 (전화번호 자체 레이트리밋) | **true** (의도적 — 남용 게이트, §6) | ACTIVE v12 | -| `verify-phone-code` | 전화 인증번호 검증 (is_used 처리) | 없음 | **true** (의도적 — 남용 게이트, §6) | ACTIVE v12 | -| `signup` | 회원가입 (전화 인증 완료 번호만) | 없음 | **true** (의도적 — 남용 게이트, §6) | ACTIVE v10 | -| `login` | ID/비번 로그인 → access JWT(+refresh) 발급 | 없음 (토큰 발급 단계) | false | ACTIVE v18 | +| `send-phone-code` | 전화 인증번호(6자리) 발급 + Solapi SMS 발송 | 없음 (전화번호 자체 레이트리밋) | **true** (의도적 — 남용 게이트, §6) | ACTIVE v20 | +| `verify-phone-code` | 전화 인증번호 검증 (is_used 처리) | 없음 | **true** (의도적 — 남용 게이트, §6) | ACTIVE v19 | +| `signup` | 회원가입 (전화 인증 완료 번호만) | 없음 | **true** (의도적 — 남용 게이트, §6) | ACTIVE v19 | +| `signup-lite` | 간이 회원(후기 전용) 인증 + 계정 확보 + 단명 토큰 (0029) | 없음 | **true** (의도적 — 남용 게이트, §6) | ACTIVE v1 | +| `login` | ID/비번 로그인 → access JWT(+refresh) 발급 | 없음 (토큰 발급 단계) | false | ACTIVE v26 | | `refresh` | refresh 토큰 회전 → 새 access+refresh 쌍 | refresh 토큰 (바디) | false | ACTIVE v6 | | `logout` | refresh family 전체 회수 (멱등) | refresh 토큰 (바디) | false | ACTIVE v3 | | `change-password` | 비밀번호 변경 + 전 세션 무효화 + 현 기기 재발급 | 커스텀 JWT Bearer | false | ACTIVE v6 | @@ -30,11 +32,18 @@ | `sync-dong-centroids` | 행정동 중심좌표 채우기 (지오코딩 배치, 멱등) | 커스텀 JWT Bearer | false | ACTIVE v3 | | `send-push` | pending 알림 FCM(HTTP v1) 발송 | `x-push-secret` 공유 시크릿 | false | ACTIVE v3 | -총 16개 함수. 로컬 소스 16개 = 원격 배포 16개 (1:1 일치, 누락/고아 없음). +위 표는 17개. **원격 배포는 21개**로, 아래 4개는 이 문서에 아직 항목이 없다(0028 업체 인증·공유 뷰어 계열, 2026-07-27 확인). + +| 슬러그 | 용도 | verify_jwt | 배포 상태 | 설계 문서 | +|---|---|---|---|---| +| `apply-business` | 업체 인증 신청 | false | ACTIVE v6 | 0025 | +| `check-business-no` | 사업자등록번호 국세청 조회 | false | ACTIVE v6 | 0025 | +| `purge-business-docs` | 업체 증빙 서류 파기 배치 (pg_cron, `x-purge-secret`) | false | ACTIVE v6 | 0025 | +| `share-view` | 공유 링크 뷰어 (QR·카톡 — 사람은 302, 크롤러는 서버 렌더링) | false | ACTIVE v18 | 0028 · 0029 | 참고: `supabase/functions/supabase/` 디렉터리는 **함수가 아니라** Supabase CLI가 남긴 `.temp/linked-project.json`(프로젝트 링크 캐시) 아티팩트다. `verify-phone-code/supabase/.temp/`에도 동일 아티팩트가 하나 더 있다(함수 폴더 안에서 CLI를 실행한 흔적). -인증 모델 요약: Supabase Auth(GoTrue)를 쓰지 않는 **완전 커스텀 인증**이다. `login`이 HS256 access JWT(프로젝트 JWT Secret으로 서명, `sub/role=authenticated/aud=authenticated/iss=supabase/tv` 클레임)를 직접 발급하고, PostgREST가 네이티브로 검증하며 `app.uid()`가 `status='active'` + `token_version(tv)` 일치를 매 요청 게이트한다. 엣지 함수들은 게이트웨이 `verify_jwt`를 끄고 `_shared/auth.ts`(또는 각 함수 내 동일 로직 복제본)로 **수동 검증**한다 — 함수별 `verify_jwt` 값은 `supabase/config.toml`(2026-07-02 추가)에 명시되어 재배포 시 결정론적으로 적용된다(12개 false, 전화인증/가입 3개만 true). DB 접근은 전부 `service_role` 클라이언트 경유(관련 테이블·RPC에 anon/authenticated GRANT 없음). +인증 모델 요약: Supabase Auth(GoTrue)를 쓰지 않는 **완전 커스텀 인증**이다. `login`이 HS256 access JWT(프로젝트 JWT Secret으로 서명, `sub/role=authenticated/aud=authenticated/iss=supabase/tv` 클레임)를 직접 발급하고, PostgREST가 네이티브로 검증하며 `app.uid()`가 `status='active'` + `token_version(tv)` 일치를 매 요청 게이트한다. 엣지 함수들은 게이트웨이 `verify_jwt`를 끄고 `_shared/auth.ts`(또는 각 함수 내 동일 로직 복제본)로 **수동 검증**한다 — 함수별 `verify_jwt` 값은 `supabase/config.toml`(2026-07-02 추가)에 명시되어 재배포 시 결정론적으로 적용된다(전화인증/가입 계열 4개 — send-phone-code·verify-phone-code·signup·signup-lite — 만 true, 나머지 false). DB 접근은 전부 `service_role` 클라이언트 경유(관련 테이블·RPC에 anon/authenticated GRANT 없음). ## 2. 공용 모듈 (_shared) @@ -86,6 +95,17 @@ 3. `signup` `{ username, password, nickname, user_type, phone, marketing_opt_in? }` → 엣지에서 argon2id 해싱(_shared/passwords) 후 `signup_user` RPC(SECURITY DEFINER, users INSERT + 약관 동의 시각 기록). 인증 안 된 번호면 `phone_not_verified`(403). 4. 이후 `login`으로 토큰 획득. +### 3.2-1 간이 회원 흐름 (0029 — 가입 없이 후기만) + +정식 가입과 **완전히 다른 경로**다. 3단계가 아니라 2단계이고, `verify-phone-code`를 거치지 않는다. + +1. `send-phone-code` `{ phone, purpose:'review' }` → 코드 SMS. +2. `signup-lite` `{ phone, code, privacy_consent:true }` → 코드 검증 + 계정 확보 + **15분 JWT** 를 한 번에. + +발급된 토큰은 저장하지 않는다(앱 메모리 전용, 게시 직후 폐기). 계정은 `status='lite'`라 후기 작성 외 모든 기능에서 차단되고, 후기 1건마다 1·2단계를 다시 밟는다(`add_facility_review`가 최근 15분 내 `review` 인증을 서버에서 요구). + +나중에 **같은 번호로 정식 가입**하면 `signup_user`가 그 행을 승격시킨다(새 계정을 만들지 않는다) — 기존 후기와 방문 회차가 그대로 이어지고 작성자 표시가 마스크에서 닉네임으로 바뀐다. + ### 3.3 로그인 → 갱신 → 로그아웃 1. **login** `{ username, password }` (+헤더 `x-client-refresh: 1` = refresh 지원 capability): @@ -113,14 +133,15 @@ - **엔드포인트**: `POST /functions/v1/send-phone-code` — verify_jwt=**true** (config.toml로 고정. publishable 키 요구를 얇은 남용 게이트로 사용, §6 참고) - **인증**: 없음(로그인 전 단계). 남용은 자체 레이트리밋으로 방어. -- **요청 바디**: `{ phone: string(필수, 정규화 후 /^01\d{8,9}$/), purpose?: 'signup' | 'password_reset' (기본 'signup') }`. 전화번호는 `normalizePhone`으로 정규화(+82→0). +- **요청 바디**: `{ phone: string(필수, 정규화 후 /^01\d{8,9}$/), purpose?: 'signup' | 'password_reset' | 'review' (기본 'signup') }`. 전화번호는 `normalizePhone`으로 정규화(+82→0). - **응답**: - 200 `{ ok: true, expires_in_sec: 300 }` - 400 `invalid_json` / `invalid_phone` / `invalid_purpose` - 429 `{ error: "rate_limited", retry_after_sec: 60 }` - 500 `server_misconfigured`(Solapi env 누락) / `internal_error` - 502 `{ error: "sms_send_failed", detail: }` -- **내부 로직**: ① `phone_verifications` 테이블에서 같은 phone+purpose로 최근 60초 내 발급 이력 count → 있으면 429 ② 6자리 코드 생성(`crypto.getRandomValues` mod 1e6) + `phone_verifications` INSERT(`expires_at = now+5분`) ③ Solapi로 SMS 발송(`[PawMate] 인증번호 XXXXXX (5분 내 입력)`). DB는 service_role 전용(RLS 정책 없음). +- **내부 로직**: ⓪ 목적별 계정 존재 검증 — `signup`은 가입된 번호면 409 `phone_taken`, `password_reset`은 미가입이면 404 `user_not_found`. **`review`는 어느 분기에도 걸리지 않는다**(존재 여부 무관 발송) ① `phone_verifications` 테이블에서 같은 phone+purpose로 최근 60초 내 발급 이력 count → 있으면 429 ② 6자리 코드 생성(`crypto.getRandomValues` mod 1e6) + `phone_verifications` INSERT(`expires_at = now+5분`) ③ Solapi로 SMS 발송(`[PawMate] 인증번호 XXXXXX (5분 내 입력)`). DB는 service_role 전용(RLS 정책 없음). +- **`review` 목적(0029)**: 간이 후기용. `signup`을 재사용할 수 없다 — 그쪽은 이미 가입된 번호에 발송을 거부하는데 간이 후기는 **정식 회원도 써야** 하기 때문이다. 목적을 나누면 후기용 인증이 가입에 전용되는 것도 함께 막힌다. ⚠️ `phone_verifications_purpose_check` 제약에도 값을 추가해야 한다(안 하면 INSERT가 막힌다). - **시크릿/환경변수**: `SOLAPI_API_KEY`, `SOLAPI_API_SECRET`, `SOLAPI_SENDER`, `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, (`ALLOW_ORIGIN`) - **정책**: 코드 TTL 5분. 동일 번호+목적 60초 1회 발급 제한. @@ -128,7 +149,8 @@ - **엔드포인트**: `POST /functions/v1/verify-phone-code` — verify_jwt=**true** (config.toml로 고정, §6) - **인증**: 없음. -- **요청 바디**: `{ phone: string(필수, /^01\d{8,9}$/ 정규화 후), code: string(필수, /^\d{6}$/), purpose?: 'signup' | 'password_reset' (기본 'signup') }` +- **요청 바디**: `{ phone: string(필수, /^01\d{8,9}$/ 정규화 후), code: string(필수, /^\d{6}$/), purpose?: 'signup' | 'password_reset' | 'review' (기본 'signup') }` +- ⚠️ **`review`는 보통 이 함수를 거치지 않는다** — `signup-lite`가 검증·계정생성·토큰발급을 한 번에 한다(사유는 해당 항목). 화이트리스트에 둔 것은 재사용 여지를 위해서다. - **응답**: - 200 `{ verified: true }` - 400 `invalid_json` / `invalid_phone` / `invalid_code` / `invalid_purpose` / `{ verified: false, error: "code_mismatch_or_expired" }` @@ -156,6 +178,26 @@ - **내부 로직**: 입력 검증 → **argon2id 해싱(_shared/passwords, hash-wasm)** → `signup_user` RPC(SECURITY DEFINER) 호출 — 전화 인증 완료 확인, `users` INSERT(terms_agreed_at·마케팅 동의 기록). RPC가 raise한 커스텀 에러코드를 HTTP 코드로 매핑. - **시크릿**: `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, (`ALLOW_ORIGIN`). CORS/json 헬퍼를 파일 내 자체 정의(_shared 미사용). - **정책**: `verify-phone-code(purpose='signup')` 완료된 번호만 가입 가능. +- **간이 회원 승격(0029)**: 같은 번호가 이미 있고 `status='lite'`면 `phone_taken` 대신 그 행을 **UPDATE로 승격**한다(username/password_hash/nickname/user_type 채우고 `status='active'`). 이게 없으면 간이로 후기를 쓴 사람이 **정식 가입을 아예 못 한다**(`users_phone_uq` 때문). 승격이므로 기존 후기·방문 회차가 그대로 이어진다. `lite`가 아닌 기존 계정은 종전대로 409 `phone_taken`. + +### signup-lite + +- **엔드포인트**: `POST /functions/v1/signup-lite` — verify_jwt=**true** (config.toml로 고정, §6) +- **인증**: 없음(로그인 전). 코드 소지가 곧 인증이다. +- **용도**: 회원가입 없이 **시설 후기만** 쓰려는 손님용(0029). 매장 QR로 들어온 비로그인 사용자가 후기 '게시'를 누르는 순간 호출된다. +- **요청 바디**: `{ phone: string(필수, 정규화 후 /^01\d{8,9}$/), code: string(필수, /^\d{6}$/), privacy_consent: true(필수) }` +- **응답**: + - 200 `{ ok: true, token: , expires_in: 900, user: { id: , display_name: "***-1***-**78" } }` + - 400 `invalid_json` / `invalid_phone` / `invalid_code` / `privacy_consent_required` / `{ verified: false, error: "code_mismatch_or_expired" }` + - 403 `account_unavailable`(정지·탈퇴 계정) / `phone_not_verified` + - 429 `rate_limited` + - 500 `server_misconfigured`(JWT_SECRET 누락) / `internal_error` +- **내부 로직**: ① 동의 검증(서버에서도 막는다 — 클라이언트 체크박스는 우회 가능) ② 레이트리밋(번호 10회/10분, IP 30회/10분) ③ `phone_verifications`에서 `purpose='review'` 미사용·미만료 최신 1건 조회 → 코드 대조 ④ `is_used=true` UPDATE ⑤ `signup_lite_user(phone, consent)` RPC — 같은 번호가 있으면 **새로 만들지 않고 그 계정을 반환**(정식 회원 포함, 방문 회차 연속성 유지), 없으면 `status='lite'`로 생성 ⑥ `token_version` 조회 후 `signAccess`로 15분 JWT 발급. +- **왜 `verify-phone-code`를 따로 부르지 않나**: 그 함수는 코드를 사용처리만 하고 끝난다. 뒤이어 별도 호출로 세션을 발급하면 그 사이 같은 번호로 아무나 `signup-lite`를 때려 **남이 받은 인증으로 세션을 가로챌 수 있다**(정식 `signup`은 비밀번호가 한 겹 더 있어 이 창이 없다). 간이 경로는 코드 소지가 곧 로그인이므로 검증·계정·토큰을 한 호출에 묶는다. +- **토큰 성격**: TTL 15분, **저장 금지**. 앱은 메모리에만 두고 후기 게시 직후 버린다. DB의 `add_facility_review`도 같은 15분 창의 `review` 인증을 요구하므로 둘이 어긋나지 않는다. 다음 후기를 쓰려면 문자 인증을 다시 받는다. +- **권한 범위**: 이 토큰으로는 **후기 작성 외에 아무것도 못 한다.** 계정이 `status='lite'`라 `app.uid()`(= `status='active'` 요구)에서 제외되어 RLS·RPC 전체가 자동 차단되고, `add_facility_review`만 `app.uid_lite()`로 예외를 뚫는다. +- **시크릿**: `JWT_SECRET`, `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, (`ALLOW_ORIGIN`) +- **⚠️ 표시명 규칙 3중복**: `app.mask_phone`(DB) · 이 함수의 `maskPhone`(응답) · 앱 시트의 `_maskPreview`(입력 중 미리보기)가 같은 규칙이어야 한다. 하나만 고치면 미리보기와 실제 표시가 갈린다. ### login @@ -322,7 +364,7 @@ |---|---|---| | `SUPABASE_URL` | 전 함수(DB 접근하는 모든 함수) | 플랫폼 자동 주입 | | `SUPABASE_SERVICE_ROLE_KEY` | 전 함수(DB 접근) | 플랫폼 자동 주입. service_role 클라이언트 생성 | -| `JWT_SECRET` | login, refresh, change-password, verify-location, verify-post-photo, enroll-pet-identity, search-petcafe, resolve-region, sync-dong-centroids | Supabase 프로젝트 JWT Secret — access JWT 서명/검증 | +| `JWT_SECRET` | login, **signup-lite**, refresh, change-password, verify-location, verify-post-photo, enroll-pet-identity, search-petcafe, resolve-region, sync-dong-centroids | Supabase 프로젝트 JWT Secret — access JWT 서명/검증 | | `ALLOW_ORIGIN` | 전 함수(CORS) | 미설정 시 `*`. 운영 시 오리진 제한 가능 | | `SOLAPI_API_KEY` / `SOLAPI_API_SECRET` / `SOLAPI_SENDER` | send-phone-code | Solapi SMS (SENDER는 콘솔 사전등록 발신번호) | | `NAVER_MAP_KEY_ID` / `NAVER_MAP_KEY` | verify-location, verify-post-photo, search-petcafe, resolve-region, sync-dong-centroids | NCP Maps Reverse/Forward Geocoding (Client ID/Secret) | @@ -335,6 +377,11 @@ ## 6. 배포 현황 및 로컬-원격 차이 +> **2026-07-27 갱신**: 배포 함수는 **21개**다. 아래 표(2026-07-02, 15개)는 그 시점 스냅샷이라 이후 추가분이 빠져 있다. 이번에 확인한 변경분만 옮겨 적는다 — +> `signup-lite` v1(true, 신규 · 0029), `send-phone-code` v20, `verify-phone-code` v19, `signup` v19, `share-view` v18(false), `login` v26. +> 표에 없는 나머지: `apply-business` v6, `check-business-no` v6, `purge-business-docs` v6, `invite-guardian` v1 (전부 false). +> verify_jwt 정책은 그대로다 — 전화인증/가입 계열 4개(send-phone-code, verify-phone-code, signup, **signup-lite**)만 true, 나머지 false. + 배포 함수(2026-07-02 기준, `list_edge_functions`): 15개 전부 ACTIVE. 로컬 소스 15개와 **슬러그 기준 1:1 일치** — 원격에만 있거나 로컬에만 있는 함수 없음. | 슬러그 | 버전 | verify_jwt | 비고 |