From f2dad657925369d6eb157ec07ed56f785fbce87f Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Tue, 8 Sep 2026 13:02:11 +0900 Subject: [PATCH] =?UTF-8?q?[fix]=20site,deploy:=20=EB=B0=9C=ED=96=89?= =?UTF-8?q?=EB=B3=B8=20=EB=AA=A9=EB=A1=9D=EC=9D=98=20=EC=A0=95=EB=B3=B8=20?= =?UTF-8?q?=EC=A3=BC=EC=86=8C=EB=A5=BC=20`/s`=20=EB=A1=9C=20=E2=80=94=20?= =?UTF-8?q?=EB=81=9D=20=EC=8A=AC=EB=9E=98=EC=8B=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/s` 는 nginx `location ^~ /s/` 에 안 걸려 맨 아래 `location /` 로 떨어진다. 그래서 404 가 아니라 **빌더 SPA 셸이 200 으로** 나가고 있었다 — 실측 2026-09-08: `/s` 3.1KB `Web4Ai` · `/s/` 6.7KB 목록. 404 도 목록도 아닌 세 번째 페이지가 오리진에 있었던 셈이다. 목록만 슬래시가 붙어 있던 이유도 이것 하나였다. 색인 요청·사이트맵이 canonical 과 어긋나면 구글이 제출분을 "대체 페이지(적절한 표준 태그가 있음)" 로 분류한다 — 슬러그 쪽에서 이미 밟은 함정인데(prerender.ts 주석) 목록만 반대 형태로 남아 있었다. - nginx/site.conf.example: `location = /s` 로 목록 index.html 직접 서빙, `/s/` 는 301. `^~ /s/` 의 `index index.html` 은 남긴다 — `/s//` 가 그걸로 열린다 - 같은 파일: `absolute_redirect off`. TLS 를 앞단 Apache 가 끊어 nginx 의 `$scheme` 는 늘 `http` 다 — 기본값대로 절대 URL 을 내면 https 페이지가 http 로 내려간다 - prerender.ts: `indexUrl` 의 `+ '/'` 제거. canonical·og:url·사이트맵·llms.txt 가 이 값 하나를 쓴다 - directory.ts · AGENTS.md · DEVLOG.md: 슬래시 규칙과 근거 갱신 검증: `nginx -t` 통과 · `tsc --noEmit` 통과 · 컨테이너 실측 `/s`→200 목록 · `/s/`→301 `Location: /s`(상대) · `/s/`→200 · `/s//`→200 · `/nope`→200 앱 셸(변화 없음) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0129XqVdjDk9JmMNFAepBJvs --- AGENTS.md | 11 +- docs/DEVLOG.md | 31 +++ nginx/site.conf.example | 24 +- .../features/onboarding/Step3DataReview.tsx | 14 +- .../src/features/publish/PublishModal.tsx | 206 +++++++++++++++--- solution/site/scripts/prerender.ts | 8 +- solution/site/src/seo/directory.ts | 4 +- 7 files changed, 259 insertions(+), 39 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0c725e2..b682a08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,8 +73,15 @@ - **`AZURE_STORAGE_CONTAINER=$web`** — 셸에서 export 할 땐 반드시 작은따옴표(`'$web'`). - **슬러그 규칙은 두 곳에 있고 같아야 한다**: `site_payload.publish_slug()` ↔ `solution/shared/src/lib/slug.ts publishUrl`. 어긋나면 발행은 성공하고 주소만 404 다. -- **디렉토리 요청 → `index.html`.** `/s/` 가 **끝 슬래시 없이** 열려야 한다. - 정적 서버를 바꾸든 nginx 설정을 만지든 이 규칙부터 확인한다. +- **★ 발행본 주소는 끝 슬래시가 없다 — 목록 페이지 `/s` 도 마찬가지다.** + canonical · 사이트맵 · llms.txt · 서치콘솔 색인 요청이 전부 이 형태여야 한다. 어긋나면 + 구글이 제출분을 **"대체 페이지(적절한 표준 태그가 있음)"** 로 분류한다 — 색인은 되는데 + 제출 URL 은 0건으로 보이는, 눈으로 원인을 못 찾는 종류다. + → nginx 는 `location = /s` 로 목록 index.html 을 **직접** 주고 `/s/` 는 거기로 301 한다. + 이 블록을 지우면 `/s` 가 맨 아래 `location /` 로 떨어져 **빌더 SPA 셸이 200 으로 나간다** + — 404 도 목록도 아닌 세 번째 페이지가 크롤러에 잡힌다(실측 2026-09-08). + → 리다이렉트는 `absolute_redirect off` 로 **상대 Location** 이어야 한다. TLS 를 앞단 + Apache 가 끊어서 nginx 의 `$scheme` 는 늘 `http` 다 — 절대 URL 로 내면 https→http 다. ## 코드 규약 diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index bf68dea..14852b8 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,37 @@ --- +## 2026-09-08 — 발행본 목록의 정본 주소를 `/s` 로 — `/s` 가 앱 셸을 200 으로 주고 있었다 + +**무슨 일** +사이트맵에서 끝 슬래시가 붙은 줄이 무엇이냐는 물음에서 시작했다. 슬러그 페이지 +(`/s/`)는 이미 슬래시가 없었고, 붙은 건 호스트 루트(`/`)와 목록 페이지(`/s/`) 둘뿐이다. +목록만 형태가 다른 이유는 nginx 였다 — `location ^~ /s/` 는 **슬래시로 시작하는 것만** 잡고, +`/s` 는 맨 아래 `location /` 로 떨어진다. + +**그런데 그게 404 가 아니었다.** `/s` 는 200 을 주고 있었고 내용이 **빌더 SPA 셸**이다 +(실측: `/s` 3.1KB `Web4Ai` · `/s/` 6.7KB 목록). 크롤러 입장에서는 404 도 +목록도 아닌 세 번째 페이지가 오리진에 하나 더 있는 셈이었다. + +**바꾼 것** +- `nginx/site.conf(.example)`: `location = /s` 로 목록 index.html 을 직접 준다. `/s/` 는 + 거기로 301. `^~ /s/` 의 `index index.html` 은 남긴다 — `/s//` 가 그걸로 열린다 +- `absolute_redirect off`: TLS 를 앞단 Apache 가 끊어서 nginx 의 `$scheme` 는 늘 `http` 다. + 기본값대로 절대 URL 을 내면 https 페이지가 http 로 내려가는 301 이 나간다 +- `prerender.ts` `indexUrl`: `+ '/'` 제거. canonical·og:url·사이트맵·llms.txt 가 이 값 하나를 + 쓰므로 전부 같이 따라온다 + +**왜 형태를 맞추나** +색인 요청·사이트맵 URL 이 canonical 과 어긋나면 구글이 제출분을 "대체 페이지(적절한 표준 +태그가 있음)" 로 분류한다 — 색인은 되는데 제출 URL 은 0건으로 보인다. 슬러그 쪽에서 한 번 +밟은 함정이고(`prerender.ts` 주석), 목록만 반대 형태로 남아 있었다. + +**남은 것** +`/s//` 는 여전히 200 이다(canonical 로만 접힌다). 목록과 달리 사이트맵에 없어서 +크롤러가 스스로 만들어낼 주소는 아니다. + +--- + ## 2026-09-08 — 내 사이트 목록에 썸네일·주소·시각 — 발행할 때마다 그림이 바뀐다 **무슨 일** diff --git a/nginx/site.conf.example b/nginx/site.conf.example index 16b79e3..be044e8 100644 --- a/nginx/site.conf.example +++ b/nginx/site.conf.example @@ -44,10 +44,30 @@ server { image/svg+xml; # ── 발행 사이트 ──────────────────────────────────────────── + # ★ 리다이렉트는 상대 Location 으로 낸다. 기본값(absolute_redirect on)은 `$scheme` 로 + # 절대 URL 을 만드는데, TLS 는 앞단 Apache 가 끊으므로 여기 `$scheme` 는 늘 `http` 다 — + # `/s/` 를 접으면 https 페이지가 http 로 내려가는 리다이렉트가 나간다. + absolute_redirect off; + + # ★ 발행본 목록의 정본 주소는 **`/s`** 다 — 슬러그 페이지(`/s/`)와 형태를 맞춘다. + # 이 블록이 없으면 `/s` 는 `^~ /s/` 에 안 걸려 맨 아래 `location /` 로 떨어지고 + # **빌더 SPA 셸이 200 으로 나간다.** 404 도 목록도 아닌 세 번째 페이지가 크롤러에 + # 잡힌다(실측 2026-09-08: `/s` 3.1KB 앱 셸 · `/s/` 6.7KB 목록). + location = /s { + root /srv/sites; + try_files /s/index.html =404; + add_header Cache-Control "public, max-age=300, must-revalidate"; + } + + # 옛 주소. 사이트맵·서치콘솔에 `/s/` 로 제출된 것이 남아 있다. + location = /s/ { + return 301 /s; + } + # ^~ 로 잡아 아래 정규식 location 들이 끼어들지 못하게 한다. location ^~ /s/ { - # ★ `/s/` 자체(발행본 목록 페이지)를 위해 필요하다. try_files 의 첫 인자 `$uri` 가 - # 끝 슬래시면 nginx 는 **디렉토리 검사**로 읽고, 디렉토리가 있으면 거기서 멈춘다 — + # ★ `/s//`(끝 슬래시) 를 위해 필요하다. try_files 의 첫 인자 `$uri` 가 끝 + # 슬래시면 nginx 는 **디렉토리 검사**로 읽고, 디렉토리가 있으면 거기서 멈춘다 — # index 지시자가 없으면 그 순간 403 이다(=404 로도 안 떨어진다). index index.html; # $uri/ 를 거치면 nginx 가 끝 슬래시로 301 을 내보낸다. 크롤러가 리다이렉트를 diff --git a/solution/frontend/src/features/onboarding/Step3DataReview.tsx b/solution/frontend/src/features/onboarding/Step3DataReview.tsx index 713c468..110cbac 100644 --- a/solution/frontend/src/features/onboarding/Step3DataReview.tsx +++ b/solution/frontend/src/features/onboarding/Step3DataReview.tsx @@ -320,8 +320,20 @@ export function Step3DataReview() { 가게만 채널을 긁을 수 있습니다 — 2단계에서 지도 검색으로 가게를 확인하시면 주소 붙여넣기와 자동 찾기가 모두 열립니다.

+ {/* ★ 이 길로 나가면 **발행까지 못 간다** — 서버에 사업장이 없어서 구울 대상이 + 없고, 발행 모달은 [내 가게 확인하러 가기] 만 내놓는다(PublishModal + PlaceFirstPanel). 예전에는 아무 말 없이 통과시켜서, 사장님은 사이트를 다 + 만든 뒤 발행을 누르는 자리에서야 그 사실을 알았다. + 버튼은 남긴다 — 검증을 못 통과한 분이 화면을 구경할 길까지 막지는 않는다. */} +

+ + 이대로 진행하면 화면은 만들 수 있지만 발행은 되지 않습니다. + {' '} + 발행본은 확인된 가게 한 곳에 묶여서, 가게 확인 전에는 만들어 둘 페이지가 + 없습니다. 둘러보신 뒤 언제든 가게를 확인하시면 그때 발행이 열립니다. +

diff --git a/solution/frontend/src/features/publish/PublishModal.tsx b/solution/frontend/src/features/publish/PublishModal.tsx index 3b66929..755ea91 100644 --- a/solution/frontend/src/features/publish/PublishModal.tsx +++ b/solution/frontend/src/features/publish/PublishModal.tsx @@ -1,21 +1,29 @@ import {useCallback, useEffect, useMemo, useState} from 'react'; +import {useNavigate} from 'react-router'; import { AlertTriangle, ArrowUpRight, Check, Copy, ExternalLink, + Eye, Loader2, RefreshCw, + Search, ServerCrash, ShieldAlert, + Store, } from 'lucide-react'; import {publishUrlString, toSlug} from '@o2o/shared'; +import {getAccessToken} from '@/api'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Dialog} from '@/components/ui/dialog'; +import {SignInForm} from '@/features/auth/SignInForm'; +import type {WizardStep} from '@/features/onboarding/wizardUrl'; import {notify, notifyApiError} from '@/lib/notify'; import {cn} from '@/lib/utils'; +import {useAuthStore} from '@/stores/auth'; import {useBuilderStore} from '@/stores/builder'; import {runPublishGate, type GateFinding} from './publishGate'; import {checkSiteSlug, reserveSiteSlug} from './siteSlug'; @@ -34,6 +42,27 @@ import { // 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다. // (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다) +/** + * 가게 확인이 안 끝난 사장님을 돌려보낼 곳. + * + * ★ 단계 이름은 wizardUrl 이 소유한다 — 문자열을 그냥 박아 두면 이름이 바뀌는 날 + * 주소창의 step 을 아무도 못 알아보고 조용히 첫 화면이 열린다(defaultStep). + */ +const PLACE_SEARCH_STEP: WizardStep = 'search'; +const PLACE_SEARCH_URL = `/builder?step=${PLACE_SEARCH_STEP}`; + +/** + * 발행을 **시작조차 할 수 없는** 상태. 서버를 부를 수 없으므로 발행 버튼을 그리지 않는다. + * + * ★ 예전에는 이 두 경우가 [지금 발행하기] 로 그대로 흘러 들어와, 서버를 한 번도 부르지 않고 + * 성공 토스트를 띄운 뒤 [사이트 열기] 까지 그렸다. 그 주소는 아무것도 굽지 않은 주소라 + * 404 였고 내 사이트 목록에도 없었다 — 사장님만 발행됐다고 믿는 상태가 남았다. + * ★ 'place' 가 진짜 함정이다. 로그인은 했는데(에디터 관문을 지났다) 3단계의 + * [발행 없이 화면만 둘러보기] 로 나오면 서버에 사업장이 없다 — 화면은 끝까지 도는데 + * 발행만 안 된다. + */ +type PublishBlocker = 'signin' | 'place'; + export function PublishModal() { const isOpen = useBuilderStore((s) => s.isPublishModalOpen); const close = useBuilderStore((s) => s.closePublishModal); @@ -57,11 +86,28 @@ export function PublishModal() { /** * 실사업장이면 진짜 빌드를 태운다(`POST /site/build {publish:true}` → 잡 폴링). - * ★ placeId 가 없는 데모 경로는 예전처럼 주소만 확정한다 — 없는 사업장을 서버에 빌드시킬 수 없다. + * ★ 그럴 수 없는 상태(로그인 전 · 사업장 미확정)에서는 발행 버튼 자체를 그리지 않는다. + * PublishBlocker 주석 참고. */ const publisher = usePublishSite(placeId); const {state, reset} = publisher; + const navigate = useNavigate(); + + /* + * ★ 토큰은 스토어 밖(custom-fetch)에 있어 구독할 수 없다. 로그인이 auth 스토어의 user 도 + * 함께 심으므로(lib/session.establishSession) 그걸 **재렌더 신호로만** 구독한다 — + * 없으면 아래 로그인 폼으로 로그인을 마쳐도 모달은 계속 "로그인해 주세요" 로 남는다. + */ + useAuthStore((s) => s.user); + + /** 판정 기준은 usePublishSite.isLive 와 같다(placeId + 토큰) — 갈리면 버튼과 실제가 어긋난다. */ + const blocker: PublishBlocker | null = publisher.isLive + ? null + : getAccessToken() + ? 'place' + : 'signin'; + const gate = useMemo( () => runPublishGate({ @@ -110,7 +156,12 @@ export function PublishModal() { if (!isOpen) reset(); }, [isOpen, reset]); - const isDone = state.phase === 'published' || (!publisher.isLive && Boolean(publishedUrl)); + /** + * ★ 서버가 발행을 확정한 것만 '발행됨'이다. + * 예전에는 `!isLive && publishedUrl` 도 완료로 쳤는데, 그 publishedUrl 을 채우던 것이 + * 서버를 한 번도 부르지 않는 가짜 경로였다 — 굽지도 않은 주소에 [사이트 열기] 가 붙었다. + */ + const isDone = state.phase === 'published'; /** * 이미 한 번 발행한 사이트인가 — 문구를 '발행'과 '재발행'으로 가른다. @@ -149,25 +200,22 @@ export function PublishModal() { }, [slug, placeId]); const handlePublish = async () => { - if (!gate.canPublish || publisher.isPublishing || !slugReady) return; + // ★ blocker 가 있으면 이 버튼은 그려지지도 않는다. 그래도 한 번 더 막는다 — + // 서버를 못 부르는 상태로 여기를 지나가는 것이 곧 '가짜 발행'이다. + if (blocker || !gate.canPublish || publisher.isPublishing || !slugReady) return; - if (publisher.isLive) { - // ★ 주소를 먼저 확정하고 빌드한다. 순서가 반대면 주소 없는 사이트가 발행되고, - // 그 뒤에 주소를 붙이면 이미 색인된 주소가 하나 더 생긴다. - if (!isSlugLocked) { - try { - await reserveSiteSlug(placeId ?? '', slug); - } catch (error) { - notifyApiError(error, '주소를 확정하지 못했습니다.'); - return; - } + // ★ 주소를 먼저 확정하고 빌드한다. 순서가 반대면 주소 없는 사이트가 발행되고, + // 그 뒤에 주소를 붙이면 이미 색인된 주소가 하나 더 생긴다. + if (!isSlugLocked) { + try { + await reserveSiteSlug(placeId ?? '', slug); + } catch (error) { + notifyApiError(error, '주소를 확정하지 못했습니다.'); + return; } - // 진짜 게이트는 서버에 있다(services/publish_gate). 여기 gate 는 왕복을 줄이는 사전 점검일 뿐이다. - publisher.publish(); - return; } - setPublishedUrl(url); - notify.success('발행 준비가 끝났습니다', '확인된 정보만 담긴 정적 페이지가 생성됩니다.'); + // 진짜 게이트는 서버에 있다(services/publish_gate). 여기 gate 는 왕복을 줄이는 사전 점검일 뿐이다. + publisher.publish(); }; const handleCopy = async () => { @@ -184,11 +232,25 @@ export function PublishModal() { - {isDone ? ( + {/* ★ 로그인 전에는 여기에 아무 버튼도 두지 않는다 — 다음 행동(로그인)은 본문의 + 폼이고, 옆에 [발행하기] 를 세워 두면 누를 수 있는 것처럼 보인다. */} + {blocker === 'signin' ? null : blocker === 'place' ? ( + + ) : isDone ? (