Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 232 additions & 0 deletions 0029.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
# 0029 — 간이 회원(lite): 가입 없이 후기 남기기 · 매장 QR 직행 · 비로그인 개방

> 상태: **구현·배포 완료**(2026-07-27). 작성·구현 같은 날.
> 범위: 회원가입 없이 전화번호 인증만으로 시설 후기를 작성하는 경로(`users.status='lite'`),
> 매장 QR 이 후기 작성 화면으로 직행하는 딥링크(`/r/<facilityId>`), 그 동선에 필요한
> 비로그인(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/<facilityId>` 로 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/<postId>` | 게시글 상세 | 게시글 공유 |
| `/u/<userId>` | 업체 프로필 | (예비 — QR 은 쓰지 않음) |
| `/r/<facilityId>` | 후기 작성 | **매장 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/<facilityId>`, `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` 링크를 관리자 화면에서 발급해야 한다.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
Loading
Loading