웹 이식 Phase A — 웹 기동·데스크톱 크롬·공유 링크 진입#196
Conversation
웹은 앱 유입 퍼널로 만든다(docs/web-port.md). 커뮤니티·후기 열람과 회원가입만 웹에서 하고, 지도·동네 인증·글쓰기·채팅은 앱으로 유도한다. 웹 기동 — 빌드가 아니라 런타임이 문제였다. `flutter build web` 은 손대기 전에도 통과했지만, main() 의 네이버 지도 초기화가 runApp 이전에 MissingPluginException(initializeNcp) 을 던져 흰 화면이었다. · dart:io 제거 — Platform.isIOS/isAndroid → utils/platform_info.dart. 웹에서 컴파일은 되고 런타임에만 UnsupportedError 라 빌드로는 안 잡힌다. 임시파일 삭제는 utils/temp_file.dart 조건부 import 파사드로. · 네이티브 전용 플러그인은 파사드 없이 kIsWeb 가드로 호출만 막는다 (지도 초기화·FCM 셋업·영상 포스터 생성). · MainScreen.visibleTabs — 탭 상수는 위치가 아니라 정체성으로 고정하고 표시 순서만 플랫폼별로. 딥링크(tabRequest)는 없는 탭이면 무시된다. 웹은 검색·커뮤니티·마이 3탭. · CI 에 웹 빌드 + dart:io 직접 import 금지 게이트 추가. 데스크톱은 크롬만 분리 — 본문(카드·타이포·모션)은 폭과 무관하게 앱과 동일하고 바뀌는 건 내비게이션 위치뿐이다. · SpringyNavBar 에 axis 추가. 세로 레일은 별도 위젯이 아니라 같은 위젯이라 스프링·스쿼시·근접 보간이 같은 코드를 탄다. · widgets/app_shell.dart — MaterialApp.builder(Navigator 바깥)에 꽂아 본문을 폰 폭 컬럼으로 묶고 레일을 그 바깥에 둔다. 상세 라우트가 얹혀도 유지된다. · 폭 판정은 전부 kIsWeb 게이트 — 네이티브 레이아웃 무변경. 공유 링크 → 웹앱: go.pawmate.kr/s 는 그대로 두고(OG 미리보기·즉시 페인트·퍼널 계측 유지) app.pawmate.kr/p/<postId> 로 잇는 진입만 받는다. 비로그인이면 웰컴 대신 게스트 메인을 깔고 그 위에 상세를 얹어, 닫으면 커뮤니티 피드로 이어진다. 서버측(CTA·post id 노출)은 pmdb 에 별도 PR. 커뮤니티 피드는 폴백이 이미 동작해 서버 작업이 없다 — feed_region_codes 가 미인증 사용자에게 NULL 을 반환하고 fetchFeed 는 그때 지역 필터를 걸지 않는다. 검증: analyze 무결점, 테스트 162개 통과, 릴리스 웹 번들을 Chrome 에서 기동해 웰컴 → 게스트 진입 → 피드·상세, 데스크톱 레일과 모바일 하단바 전환, /p/<id> 공유 진입까지 확인. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
web/ 이 Flutter 기본 템플릿 그대로였다. 실제 도메인에 올리면 전부 사용자에게 보이는 것들이라 배포 전에 정리한다. · 아이콘 5종이 Flutter 기본 로고(파란 F)였다 — assets/icon/app_icon.png 에서 192·512·maskable·favicon 재생성. 파비콘·PWA 아이콘·홈화면 아이콘에 그대로 쓰인다. · index.html — 제목 'pawmate'/설명 'A new Flutter project' 교체, 한국어 lang, OG 태그(웹앱 주소를 직접 공유했을 때의 폴백. 공유 링크 자체는 go.pawmate.kr 서버 렌더링 뷰어가 담당한다), theme-color 라이트/다크. · 스플래시 — CanvasKit 은 수 MB라 첫 페인트까지 몇 초 걸린다. 그동안 흰 화면 대신 앱 배경색(cream #F5EFE3 / 다크 #121212)과 로고를 깔고, flutter-first-frame 이벤트에 걷어낸다. prefers-reduced-motion 존중. · manifest.json — 이름·설명·색을 앱 기준으로(기존값은 Flutter 블루 #0175C2). Cloudflare Pages(pawmate-web)에 배포해 pages.dev 에서 확인: SPA 폴백(_redirects) 동작, /p/<postId> 딥링크로 게시글 상세 진입, 데스크톱 레일·실데이터 정상. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
배포 결과와 배포 중 걸린 함정, 그리고 실제 도메인에서만 드러난 증상을 남긴다. · Cloudflare Pages(pawmate-web) → https://app.pawmate.kr · 커스텀 도메인 함정: wrangler CLI 에 Pages 커스텀 도메인 명령이 없고, wrangler OAuth 토큰은 zone(read) 뿐이라 DNS 를 못 만든다. 도메인 등록은 API 로 되지만 CNAME 은 대시보드에서 사람이 넣어야 한다. API 로 등록하면 대시보드 마법사의 DNS 자동 생성 단계가 건너뛰어져 그 행에 버튼이 안 생긴다. 전파 중 몇 분간 522 는 정상. · B6(초기 로딩) 부분 완료로 갱신 — index.html·manifest·아이콘 정비 반영. · B7 신설: 첫 로드에서 몇 초간 한글이 전부 두부(□)로 보인다. 스플래시는 첫 프레임에 걷히는데 CanvasKit 이 CJK 글리프를 번들하지 않고 폴백 폰트를 첫 프레임 이후에 받기 때문. localhost 에서는 빨라서 안 보였고 실제 도메인에서 드러났다. 한글 폰트 번들로 닫는다 — 외부에 URL 알리기 전 필수. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
첫 로드에서 몇 초간 한글이 전부 □ 로 보이던 문제. 처음엔 한글 폰트 번들로
잡으려 했으나 실측 결과 처방이 틀렸다.
· 폴백으로 받는 Noto Sans KR = 32KB (unicode-range 조각 4개)
· 번들 시 = 1.15MB × 굵기 수 (앱은 w400~w800 사용)
· canvaskit.wasm(gstatic) = 5.3MB
32KB 를 없애자고 2~4MB 를 첫 프레임 앞에 세우는 꼴이라 손해다. 진짜 원인은
용량이 아니라 순서와 연결 비용이었다 — 첫 프레임을 그린 뒤에야 빠진 글자를
발견하고, 그 시점에 fonts.gstatic.com 연결을 DNS 부터 새로 맺는다.
· index.html 에 fonts.gstatic.com preconnect — 핸드셰이크를 미리 끝낸다.
google_fonts 의 Baloo2(웰컴 로고)도 같은 오리진이라 함께 덮인다.
· 빌드에 --no-web-resources-cdn — CanvasKit 을 같은 오리진에서 서빙한다.
Cloudflare brotli 로 2.0MB 만 전송(gstatic 원본 5.4MB). 서드파티 의존도
사라진다. 배포 때마다 필요한 플래그라 문서에 명시.
콜드 로드 실측(적용 후): main.dart.js 0.74→2.6s, canvaskit.wasm 0.74→2.4s,
폰트 요청은 3.7s 에 시작해 7ms 에 완료. 두부 구간 수 초 → 100ms 대.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
웹에서 flutter_secure_storage 를 쓰지 않는다. 그 웹 구현은 localStorage + AES 인데
복호화 키도 같은 localStorage 에 있어 사실상 난독화다.
먼저 계획 문서의 전제가 틀렸음을 바로잡는다. "무상태 JWT exp 30일"은 옛말이고
refresh-token phase 2 이후 실제로는 access 8시간 + 회전 refresh(슬라이딩 30일·
절대 90일)이며, rt_rotate 가 재사용을 감지하면 family 를 통째로 회수한다.
httpOnly 쿠키 이관(원래 계획)은 하지 않는다 — 로그인 경로를 바꾸는 작업이라
검증 부담이 큰 데 비해, XSS 가 페이지 안에서 그대로 요청하는 건 어차피 못 막는다.
웹은 앱 유입 퍼널이므로 세션을 짧게 끊는 쪽이 맞는 교환이다.
· access·user → sessionStorage(탭 닫으면 소멸)
· refresh → 영속화하지 않고 갱신에도 쓰지 않는다. 메모리에만 들고 있다가
로그아웃 시 서버 family 회수에만 쓴다.
· 결과: XSS 로 새어나갈 수 있는 최대치가 access 하나(8시간)이고 갱신 불가.
· 앱(네이티브)은 무변경 — 30일 회전 세션 그대로.
⚠️ 로그인의 x-client-refresh:1 헤더는 그대로 유지해야 한다. 빼면 서버가 레거시
클라로 보고 30일짜리 access 를 발급해 오히려 나빠진다(login/index.ts).
구현은 session_store.dart 조건부 export(_io/_web) + session.dart 가 _store 로만
접근. 서버 변경 없음. 부수 효과로 wasm 빌드를 막던 flutter_secure_storage_web
(dart:html·package:js)이 웹 경로에서 빠졌다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
증상: 웹에서 로그인 버튼은 눌리는데 오류 토스트만 뜨고 로그인이 안 된다. (실제로는 세션이 저장된 뒤라 새로고침하면 로그인돼 있는 상태였다.) 원인은 Phase A 에서 내가 만든 구멍이다. main() 의 _setupPush() 만 건너뛰면 된다고 봤지만, PushService 는 final FirebaseMessaging _fm = FirebaseMessaging.instance; 를 **인스턴스 필드**로 갖고 있어서 PushService.instance 를 처음 참조하는 순간 Firebase 가 없다며 [core/no-app] 로 던진다. 그 참조가 하필 로그인 성공 직후 (auth_service registerToken)와 로그아웃(clearToken)에 있었다. login() 의 바깥 catch 가 이를 삼켜 network_error 로 바꾸는 바람에 원인도 안 보였다. · _fm 을 필드 → 게터로(참조 시점 지연, 웹에서는 평가되지 않는다) · init/registerToken/clearToken 에 kIsWeb 조기 반환 교훈은 문서에도 남긴다 — kIsWeb 가드는 초기화 지점이 아니라 공개 진입점마다 걸어야 하고, 게스트 둘러보기만 확인해서는 이 부류를 못 잡는다. 검증: 배포본에서 일부러 틀린 자격증명으로 시도해 CORS 프리플라이트 200 → '아이디 또는 비밀번호가 올바르지 않아요' 정상 응답 확인(이전엔 일반 오류). 성공 경로의 나머지 호출(RealtimeService.start)은 순수 Supabase 라 웹 안전. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
증상: PC 에서 창을 가로로 넓힐수록 게시글 상세가 엉뚱한 자리(거의 화면 밖)에서
펼쳐진다. 원인은 AppShell 이 Navigator **바깥**에서 본문을 가운데 컬럼으로
옮기고 좁히는데, 라우트 안은 그걸 모른다는 것.
(1) 좌표 — originRect 는 localToGlobal(창 좌표)로 잡는데 라우트는 컬럼 좌표계다.
캡처 지점이 18곳이라 규약을 "originRect 는 언제나 창 좌표"로 못박고 소비
지점 3곳(CollapsibleView 2 · ExpandRoute 1)에서 toRouteRect() 로 변환한다.
riseOriginRect 는 MediaQuery 기준이라 반대로 shellOrigin() 을 더해 맞춘다.
(2) 크기 — ConstrainedBox 는 MediaQuery 를 바꾸지 않아 컬럼 안에서도
MediaQuery.size 가 창 크기로 보고된다(모프 도착 사각형이 창 폭으로 잡힘).
컬럼 안에서 MediaQuery 를 컬럼 크기로 덮어쓴다.
이때 크롬 구성 판단까지 오염되는 함정이 있다 — useSideNav 가 컬럼 폭(460)을
보고 '좁은 화면'으로 오판해 좌측 레일과 하단 바가 동시에 나온다. 그래서
창 크기는 ShellMetrics(InheritedWidget)로 따로 내려보내고
useSideNav/useContentColumn 이 그 값을 우선 보게 한다.
셸이 없으면(네이티브·좁은 화면) 오프셋 0 · ShellMetrics 부재라 기존 동작 그대로다.
검증: 배포본에서 게스트 진입 → 카드 탭 → 모프가 카드 자리에서 펼쳐지고 컬럼에
안착, 레일은 단일 유지. analyze 무결점, 테스트 162개 통과.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
숨기면 앱에서 뭘 더 할 수 있는지 알 길이 없어 유입으로 이어지지 않는다.
보여주고 누르면 권하는 쪽으로 뒤집는다(웹 3탭 → 5탭 노출).
· visibleTabs = 아이콘으로 노출할 탭(5개, 플랫폼 무관)
· appOnlyTabs = 웹에서 {지도, 채팅} — 누르면 AppInviteDialog, 화면 전환 없음
(알약도 안 움직인다). 내용 위젯도 만들지 않아 웹 구현 없는 지도 플랫폼뷰가
붙는 것을 막는다.
· contentTabs = 실제 화면이 있는 탭. IndexedStack 은 이쪽 인덱스를 쓴다.
· 딥링크(tabRequest)가 앱 전용 탭을 가리키면 조용히 무시 — 사용자가 누른 것이
아니므로 안내를 띄우지 않는다.
AppInviteDialog 는 AuthWallDialog 와 같은 시각 언어. 스토어 주소
(Env.storeUrlIos/storeUrlAndroid, --dart-define)가 비어 있으면 — 출시 전
기본값 — 설치 버튼 대신 '출시 준비 중' 안내만 보여준다(죽은 링크 방지).
share-view Edge Function 의 STORE_URL_* 와 같은 값을 넣으면 된다.
덤으로 withTopicParticle(labels.dart) — '지도은(는)' 같은 괄호 표기 대신
받침을 보고 은/는을 고른다. 테스트 4건.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
증상: 웹에서 게시글 상세의 영상이 잘린다. 원인은 점진 블러다. 하단 패널 블러는 카드 문법을 따라 **같은 소스를 한 겹 더** 그려 ImageFiltered+ShaderMask 를 먹이는데(#184), 그 사본이 영상일 때 웹에서 깨진다. video_player_web 은 영상을 HtmlElementView(DOM <video>)로 그리고, 플랫폼 뷰는 캔버스 밖에서 합성되므로 · ImageFiltered·ShaderMask 가 먹지 않고 · ClipRect 도 따라오지 않는다 → 블러 사본으로 만든 두 번째 <video> 가 진짜 영상 위로 삐져나와 잘려 보였다. 웹에서는 포스터(정지 프레임)를 블러 원본으로 쓴다 — 이미 있던 '초기화 전' 폴백 경로로 흘려보내면 된다. σ8 로 뭉개지는 하단 패널이라 정지 프레임과 구분이 거의 안 된다. 네이티브는 기존 동작 그대로(사본 방식 유지). post_media_hero._blurSource 와 review_detail_screen 둘 다 같은 패턴이라 함께 고침. post_card 는 원래 이미지를 블러해서 영향 없음. 검증: 배포본에서 영상 게시글 상세 진입 — 영상이 꽉 차고 잘림 없음, 하단 패널 블러도 경계 없이 페이드. analyze 무결점, 테스트 166개 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
앞 커밋(블러 원본을 포스터로)으로 사본 <video> 는 사라졌지만 잘림이 남아 있었다. DOM 실측으로 진짜 원인을 잡았다. <video> 실제 렌더 박스 : 460×818, y=-50 (뷰포트 위아래로 50px 씩 초과) Flutter 가 잡은 플랫폼뷰 호스트 : 460×7 영상은 원본 크기(1080×1920) SizedBox 를 FittedBox(cover, clipBehavior:hardEdge)로 확대해 채우는데, video_player_web 의 영상은 HtmlElementView(DOM <video>)이고 플랫폼 뷰는 캔버스 밖에서 합성되므로 **클립이 먹지 않는다**. 넘치는 부분이 잘리지 않고 그대로 박스 밖으로 흘러 위아래 UI 를 덮었다. 웹은 박스를 영상 비율에 맞춰(AspectRatio) 애초에 넘칠 것이 없게 한다. cover 가 contain 이 되어 레터박스가 생기지만 뒤에 검정 배경이 이미 깔려 있어 자연스럽고, 잘림보다 낫다. 네이티브는 FittedBox 그대로. post_media_hero 는 _videoSurface 로 분기를 모으고, review_detail_screen 도 같은 패턴이라 함께 고침. media_widgets 의 전체화면 플레이어는 원래 AspectRatio 라 무관. 검증(DOM 실측, 폭 1200): 404×718 · y=0 · 위아래 초과 없음. 404/718 이 정확히 1080/1920 이라 비율도 보존된다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
증상: /p/<postId> 로 들어왔는데 상세가 안 열리고 피드에 머문다(간헐).
_openInitialWebLink 는 첫 프레임 직후 한 번만 시도하고 실패하면 조용히 포기했다.
콜드 로드에서는 canvaskit(2MB)·main.dart.js(1.3MB) 다운로드와 Supabase 첫 요청이
겹쳐 일시 실패하기 쉽다. openFromPush 는 같은 이유로 이미 재시도·대기를 갖고
있는데(주석: "백그라운드 복귀 직후엔 첫 요청이 일시 실패하곤 한다") 이쪽만 없었다.
· 네비게이터 준비를 최대 2초 대기(첫 프레임 직후라 보통 즉시).
· fetchPost 는 **예외일 때만** 최대 3회 재시도(400ms 간격). 결과가 null 이면
(삭제·비공개) 재시도해봐야 소용없으므로 바로 빠진다.
· 그래도 못 열면 말없이 피드로 떨구지 않고 토스트로 이유를 알린다.
검증: 배포본에서 유효한 공유 링크 진입 정상(영상 게시글 상세까지). 폭 430·620·
1200 에서 모두 확인. 다만 '못 찾음' 경로의 토스트는 화면에 뜨는 것을 확인하지
못했다 — 재시도·대기 자체는 동작하나 토스트 표시 여부는 미검증(원인 미상).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
전면 점검(1440폭)에서 나온 것 중 기능·디자인에 직접 걸리는 3건.
(1) 상세 위에서 레일이 먹통 — 레일은 본문 컬럼 바깥(Navigator 위)에 있어 게시글
상세가 열려도 계속 보인다. 그 상태로 누르면 아래 탭만 바뀌고 화면은 상세
그대로라 "눌러도 아무 일도 안 일어남"으로 보였다. 데스크톱 전용 증상이다
(하단 바는 상세에 가려 이 상황 자체가 없다).
→ 레일 탭은 _selectFromRail 로 분리해 MainScreen 위 라우트를 먼저 닫는다.
(2) 카테고리 칩 도달 불가 — 기본 dragDevices 는 터치·스타일러스뿐이라 마우스로
가로 목록을 끌 수 없다. 칩이 컬럼 밖으로 잘리는데 넘길 방법이 없어 뒤쪽
'입양'·'자유' 카테고리를 아예 못 썼다.
(3) 데스크톱 스크롤바 — 앱에 없는 요소가 컬럼 바깥에 뜬다.
(2)(3)은 웹 전용 ScrollBehavior 하나로 함께 잡는다(app_scroll_behavior.dart).
네이티브는 지정하지 않아 무변경.
검증(배포본): 상세 열고 레일 탭 → 상세가 닫히며 탭 전환. 칩을 마우스로 끌어
'입양'·'자유'까지 도달. 스크롤 후 스크롤바 없음.
점검에서 나온 나머지(레일-본문 간격, 정보 밀도)는 데스크톱 디자인 방향 결정에
종속되므로 보류.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
실제 로그인 상태로 점검하다 발견. 로그인 후 데스크톱에서 좌측 레일도 하단 바도 없어 **이동 수단이 아예 없다**(게스트일 때는 정상). MainScreen 은 initState 에서 navRail 에 자기 설정을 올리고 dispose 에서 null 로 지운다. 그런데 로그인은 pushAndRemoveUntil 이라 순서가 새 MainScreen.initState → navRail = 새 설정 게스트 MainScreen.dispose → navRail = null ← 방금 올린 것까지 지움 이 된다. 웰컴에서 게스트로 둘러보다 로그인하는 흐름(가장 흔한 경로)에서 항상 재현된다. 하단 바는 MainScreen 안에 있어 멀쩡하지만, 넓은 화면에서는 하단 바를 안 그리므로 결과적으로 내비게이션이 0 이 된다. dispose 는 **자기가 올린 설정일 때만** 지운다(identical 비교). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
지역인증을 웹에서 하면 법적 문제 소지가 있다는 지적을 반영. 지금까지는 웹에서
위치 화면에 '도달할 수 없다'에만 기대고 있었는데, 그 전제에 기대지 않는다.
· LocationService.getCurrentPosition 이 웹에서 즉시 deniedForever 를 반환한다.
호출부(글쓰기·지역인증·지도·촬영인증)는 모두 웹에서 도달 불가지만, 경로가
하나라도 열리면 브라우저 위치 권한 프롬프트가 뜬다 — 그것부터 막는다.
(신뢰성 이유도 같다: 브라우저 Geolocation 에는 isMocked 가 없어 조작을 못
거른다 — 동네 인증 0017 의 전제가 무너진다.)
· 프로필 편집의 '활동 지역' 행이 웹에서 **눌리는데 아무 일도 안 나는** 상태였다
(Phase A 에서 onTap 만 null 로 막고 표시는 그대로 뒀다). 라벨을 '앱에서'로
바꾸고 누르면 AppInviteDialog 로 안내한다. 하단 설명도 웹 문구로.
· 커뮤니티 글쓰기의 동네 인증 게이트도 웹이면 앱 안내로. 현재 웹에는 글쓰기
FAB 자체가 없어 도달하지 않지만 경로가 열려도 안전하게.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
게시글 공유는 이제 go.pawmate.kr/s 에서 곧장 웹앱이 뜬다(서버 쪽은 pmdb
feat/share-view-app-shell). 주소에는 토큰만 있고 토큰→게시글 해석은 service_role
전용이라 웹앱이 스스로 못 하므로, 서버가 <meta name="pm-post"> 로 실어 보낸다.
· utils/web_link_meta.dart(조건부 export) — 그 메타를 읽는다. 네이티브는 null.
· initialSharedPostId 가 주소 /p/<uuid> → 주입 메타 순으로 본다. 주입값도
uuid 형태를 검사한다.
· web/_headers — 셸은 go.pawmate.kr 오리진에서 뜨고 <base href> 로 에셋만
app.pawmate.kr 에서 받으므로 canvaskit.wasm 등이 교차 출처 fetch 가 된다.
전부 공개 정적 자산이라 * 로 연다.
케어리포트는 이 경로를 쓰지 않는다 — 수신자가 '내 아이 기록을 받을 보호자'
한 명이라 웹 탐색이 아니라 앱 설치·계정 연결이 목적이고, 그 연결은 앱에만 있다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
로그인 상태 점검에서 발견. 내비게이션에서 채팅 탭을 눌렀을 때는 앱 설치 안내가 뜨는데, **유저 프로필의 '채팅' 버튼**을 누르면 채팅방이 그대로 열렸다(실제로 방이 열리는 것까지 확인). 게시글 상세의 '채팅'과 지원자 목록도 같은 경로다. 탭만 막고 진입로를 안 막은 탓이다. 진입로가 셋(프로필·게시글 상세·지원자 목록) 이라 각각 고치는 대신 공통 함수 openDirectChat 한곳에서 막는다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
go.pawmate.kr/s 에서 웹앱 셸을 <base href> 만 바꿔 내려주는 방법을 배포해봤더니 브라우저에서 앱이 시작하자마자 죽었다. SecurityError: Failed to execute 'replaceState' on 'History': URL 'https://app.pawmate.kr/s?…' cannot be created in a document with origin 'https://go.pawmate.kr' Flutter 라우터가 히스토리를 조작할 때 교차 출처 base 를 브라우저가 막는다(서비스 워커 등록도 동일). flutter.js 에는 에셋 기준을 base href 와 분리하는 설정이 없어 (모두 document.baseURI 기준) 우회할 수 없다. 프로덕션은 시크릿을 즉시 해제해 서버 렌더링으로 되돌렸고, 지금은 302 방식으로 다시 켰다. · 셸 방식 전용이던 web_link_meta*(주입 메타 수신)와 web/_headers(교차 출처 에셋용 CORS)를 제거 — 302 는 동일 출처라 둘 다 불필요하다. · initialSharedPostId 는 주소 /p/<uuid> 만 본다. 서버 쪽 UA 분기는 pmdb feat/share-view-redirect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
매장에 나눠줄 QR 을 찍으면 미리보기를 거치지 않고 **그 매장의 후기 작성 화면**이
바로 열린다. 손님이 QR 을 찍는 목적이 후기이기 때문이다(서버 쪽 302 는 pmdb).
대상을 업체 계정이 아니라 **시설(facility)** 로 잡은 게 핵심이다. QR 을 뿌릴 매장
대부분은 아직 업체 인증 전이라 프로필이 없지만, 시설 행은 공공데이터로 이미
존재하고(전국 24,552곳) 후기도 시설에 달린다. 나중에 그 매장이 인증하면
business_profiles.matched_facility_id 로 같은 시설에 붙어 수정 권한을 갖는다 —
QR 을 먼저 뿌려도 주인이 나중에 나타나는 순서가 성립한다.
· web_link — `/p/`(게시글)·`/u/`(업체 프로필)·`/r/`(후기 작성) 세 딥링크를
_idAfter 로 통일. 서로 섞이지 않는 것까지 테스트로 잠갔다.
· FacilityRepository.fetchById — 시설 1건 조회. RPC 가 아니라 테이블 직접
읽기다(RLS `using (true)` + anon SELECT, 반경 RPC 와 달리 좌표가 불필요).
검색이 비로그인에서 통째로 죽어 있던 것도 함께 고친다. SocialRepository._uid 가
게스트에서 StateError 를 던지는데 searchUsers/listBusinesses 가 '나 제외' 필터에
그걸 쓰고 있었다 — 검색 탭 기본 화면(업체 목록)부터 예외로 죽었다. uid 가 null
이면 제외 조건과 팔로우 표시를 건너뛴다.
검색 결과에 **매장** 섹션도 추가했다. 시설이 곧 '주인 없는 프로필' 이라 상호로
찾아 후기까지 갈 수 있어야 하는데, 지금까지 검색 탭은 사용자·펫만 찾았다.
web_link.dart 의 web_link_meta import 제거는 3b09cf5 의 누락 보완이다 — 그 커밋이
web_link_meta.dart 를 지우면서 import 를 남겨 HEAD 가 컴파일되지 않는 상태였다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
회원가입 없이 후기를 남길 수 있게 한다. 쓰기 전에 가입을 요구하면 대부분
이탈하므로, 다 쓰고 **'게시'를 누르는 순간에만** 번호 인증을 청한다. 인증을
취소해도 별점·본문·사진이 그대로 남는다(초안 보존).
간이 회원은 비회원 취급이다. 서버가 users.status='lite' 로 두어 app.uid() 에서
제외하므로 채팅·게시글·펫 등 다른 기능은 전부 자동으로 막히고, 후기 작성 하나만
app.uid_lite() 로 뚫려 있다(pmdb 20260727100000). 세션도 저장하지 않는다 —
토큰은 메모리에만 있고 게시 직후 버리며, 다음 후기를 쓰려면 인증을 다시 받는다.
· SessionManager — _liteToken/_liteUserId(메모리 전용) + beginLiteSession/
endLiteSession. isLoggedIn 은 여전히 false 라 앱은 계속 게스트로 동작한다.
· storage_service — 업로드 경로의 uid 를 storageUserId 로. 버킷 RLS 가 첫 폴더를
검사하므로 토큰 주인과 일치해야 한다.
· facility_review_repository — 로그인 판정을 SessionManager.user 가 아니라
canWriteReview 로. user 가 없는 게 간이 회원의 정상 상태다.
· facility_review_screen — 비로그인은 사진을 로컬에 들고 있다가 인증 후 올린다
(Storage RLS 가 `<uid>/...` 라 인증 전에는 쓸 수 없다). 동영상은 포스터 생성이
얽혀 있어 간이 후기에서 제외하고 안내한다.
· user_profile_screen — 업체 프로필의 후기 작성 로그인 게이트 제거.
동의는 체크박스로 명시적으로 받는다. 전화번호는 개인정보이고, 나중에 같은 번호로
정식 가입할 때 이 후기들을 그 계정에 잇는 것까지가 수집 목적이라 그 목적이 동의
항목에 적혀 있어야 한다(서버도 동의 없으면 거부). 전문은 pmlegal 07 을 에셋으로
번들해 TermsScreen.liteReview 로 띄운다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
추가: 매장 QR 후기 동선 + 간이 회원 (커밋 2개)
서버 쪽은 pmdb#132, 약관 정본은 pmlegal#14 입니다. 이미 프로덕션에 배포된 상태입니다(DB·엣지 함수·Cloudflare Pages). 매장 QR 이 후기 작성으로 직행QR 을 찍으면 미리보기를 거치지 않고 그 매장의 후기 작성 화면이 바로 열립니다. 대상이 업체 계정이 아니라 시설(facility) 인 게 핵심인데, QR 을 뿌릴 매장 대부분이 아직 업체 인증 전이라 프로필이 없기 때문입니다. 시설 행은 공공데이터로 이미 있고(전국 24,552곳, 동탄 190곳) 나중에 인증하면 같은 시설에 붙습니다.
간이 회원 — 게시 시점에만 인증다 쓰고 '게시'를 누르는 순간에만 번호 인증을 청합니다. 인증을 취소해도 별점·본문·사진이 남습니다. 비로그인은 사진을 로컬에 들고 있다가 인증 후 올립니다(Storage RLS 가 세션은 저장하지 않습니다 — 토큰은 메모리에만 있고 게시 직후 버립니다. 검색이 비로그인에서 통째로 죽어 있던 것
참고 — HEAD 컴파일 불가 상태였습니다
검증
|
CI 가 두 가지로 막혀 있었다.
· 포맷 — 간이 회원 작업 파일 5개가 dart format 미적용이었다(기계적 수정).
· analyze — 직전 revert 커밋에서 web_link.dart 수정본이 빠져 삭제된
web_link_meta.dart 를 계속 import 하고 있었다. `git add -A` 에 이미 삭제된
경로를 함께 넘겨 명령이 통째로 중단됐는데 stderr 를 버려 놓쳤다.
(이후 커밋에 딸려 들어가 지금은 해소된 상태다.)
docs/web-port.md 는 결정 6 을 302 방식으로 확정하고, 폐기한 셸 서빙 방법과
그 이유(교차 출처 base href 에서 history.replaceState 가 SecurityError)를
기록으로 남긴다 — 같은 길을 다시 시도하지 않도록.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
웹을 앱 유입 퍼널로 만드는 첫 단계. 커뮤니티·후기 열람과 회원가입만 웹에서 하고, 지도·동네 인증·글쓰기·채팅은 앱으로 유도한다. 설계 전체는
docs/web-port.md.1. 웹 기동 — 빌드가 아니라 런타임이 문제였다
계획 단계의 전제("
flutter build web이 컴파일에서 깨진다")는 틀렸다. 빌드는 손대기 전에도 통과했다. 실제 증상은 브라우저 흰 화면이었고 원인은 한 줄이다:main()의 네이버 지도 초기화가runApp이전에 던져 앱이 아예 뜨지 않았다. 빌드 게이트로는 못 잡는 종류다.dart:io제거 —Platform.isIOS/isAndroid→lib/utils/platform_info.dart. 웹에서 컴파일은 되고 런타임에만UnsupportedError라 빌드로 안 잡힌다. 임시파일 삭제는utils/temp_file.dart조건부 import 파사드로.kIsWeb가드로 호출만 막는다(지도 초기화·FCM 셋업·영상 포스터 생성). Dart 코드는 멀쩡히 컴파일되므로 stub 이 필요 없었다.MainScreen.visibleTabs— 탭 상수는 위치가 아니라 정체성으로 고정하고 표시 순서만 플랫폼별로. 딥링크(tabRequest)는 없는 탭이면 무시된다. 웹은 검색·커뮤니티·마이 3탭.flutter build web --release+dart:io직접 import 금지 grep. 후자가 런타임 회귀를 막는 실질적인 게이트다.2. 데스크톱은 "크롬만" 분리
본문(카드·타이포·모션)은 폭과 무관하게 앱과 동일하고, 바뀌는 건 내비게이션 위치뿐이다.
SpringyNavBar에axis추가 — 세로 레일은 별도 위젯이 아니라 같은 위젯이다. 스프링·스쿼시·근접 보간이 같은 코드를 타므로 두 형태가 영원히 같은 모션 언어를 유지한다.widgets/app_shell.dart—MaterialApp.builder(Navigator 바깥)에 꽂는다. 레일이 본문 컬럼 바깥에 있어야 하고 상세 라우트가 얹혀도 유지돼야 해서 거기밖에 둘 곳이 없다.MainScreen은 탭 상태만navRail로 공개한다.kIsWeb게이트 — 네이티브 레이아웃 무변경(아이패드 포함).3. 공유 링크 → 웹앱 진입
go.pawmate.kr/s는 그대로 둔다(OG 미리보기·즉시 페인트·퍼널 계측 유지).app.pawmate.kr/p/<postId>로 들어오는 진입만 받는다. 비로그인이면 웰컴 대신 게스트 메인을 깔고 그 위에 상세를 얹어, 닫으면 커뮤니티 피드로 이어진다.서버측(CTA·post id 노출)은 pmdb
feat/share-view-web-cta에 별도 PR.계획에서 뺀 것
커뮤니티 피드 전국 폴백은 서버 작업이 필요 없었다.
feed_region_codes()가 미인증 사용자(is_location_verified=false또는latitude is null)에게 NULL 을 반환하고,fetchFeed는codes == null이면 지역 필터를 아예 걸지 않는다. 게스트·웹 신규 가입자 모두 이미 전국 피드가 나온다 — 브라우저에서 확인했다.검증
flutter analyze무결점 /flutter test162개 통과 / 포맷·dart:io게이트 통과/p/<uuid>공유 진입 정상,/p/not-a-uuid는 조회 없이 웰컴 폴백여담으로
flutter analyze는 통과했는데 dart2js 가 널 프로모션 에러를 잡았다(클로저에 캡처된 지역변수는 승격이 풀림). 위 웹 빌드 게이트가 바로 값을 한 사례라 해당 위치에 주석으로 남겼다.남은 것
🤖 Generated with Claude Code