From 7a6fee70c18a119e65caea72720325eea3897ace Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Fri, 4 Sep 2026 17:29:13 +0900 Subject: [PATCH 01/18] =?UTF-8?q?[fix]=20solution/frontend:=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=ED=95=B4=EB=8F=84=20=EB=9E=9C=EB=94=A9=C2=B7?= =?UTF-8?q?=EC=9A=94=EA=B8=88=EC=97=90=20=EA=B0=88=20=EA=B8=B8=EC=9D=84=20?= =?UTF-8?q?=EB=82=B8=EB=8B=A4=20=E2=80=94=20'/'=20=EA=B4=80=EB=AC=B8=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그인하면 '/' 가 무조건 /sites 로 튕겼다. 그래서 **로고를 눌러도 랜딩이 안 뜨고**, /pricing 은 살아 있는데 링크가 랜딩과 MarketingShell 에만 있어 로그인한 사장님은 주소를 직접 쳐야 했다. 요금은 쓰는 도중에 확인하는 값이지 가입 전에만 보는 값이 아니다. - app/router.tsx: Home 의 리다이렉트 제거. '/' 는 누구에게나 랜딩이다. "로그인 직후엔 내 사이트로" 는 로그인·가입 화면이 직접 보낸다. - pages/LoginPage.tsx: 도착지를 `homePath` prop 으로 받는다. 기본값 '/' 라 내부 운영 앱(자기 '/' 가 사업장 목록으로 간다)은 그대로다. 사장님 앱만 '/sites'. - pages/SignupPage.tsx: 가입 후 '/' → '/sites'. 안 그러면 관문이 없어진 지금 랜딩에 떨어진다. - 로그인·가입 화면 로고에 '/' 링크. 그 화면에서 빠져나갈 길이 하나도 없었다. - layout/AppShell.tsx: 로그아웃 → '/login' 이 아니라 '/'. 나간 사람에게 로그인 폼을 다시 들이밀지 않는다. 사이드바에 [요금] 추가. - layout/MarketingShell.tsx: 헤더 [무료로 만들기] 제거 — 히어로 입력 카드가 이미 그 자리다. 시작하는 문이 한 화면에 둘이면 어느 쪽이 진짜인지 고르게 만든다. 검증: tsc·eslint 통과. docker compose up -d --build solution-site 로 띄워 눌러 확인 — 로그인 상태에서 로고 → 랜딩, 헤더는 [이렇게 나옵니다 · 요금 · 내 사이트]. --- solution/frontend/src/app/router.tsx | 10 +++++-- .../src/components/layout/AppShell.tsx | 14 +++++++-- .../src/components/layout/MarketingShell.tsx | 26 +++++++--------- solution/frontend/src/pages/LoginPage.tsx | 30 +++++++++++++------ solution/frontend/src/pages/SignupPage.tsx | 11 ++++--- 5 files changed, 59 insertions(+), 32 deletions(-) diff --git a/solution/frontend/src/app/router.tsx b/solution/frontend/src/app/router.tsx index f8f6066..2621c72 100644 --- a/solution/frontend/src/app/router.tsx +++ b/solution/frontend/src/app/router.tsx @@ -32,12 +32,18 @@ function Home() { ); } - if (user) return ; + /* + * ★ 로그인해도 랜딩을 보여 준다 (2026-09-04, 사장님 지적) + * 예전엔 여기서 /sites 로 튕겼다. 그러면 **로그인한 사장님은 랜딩·요금에 갈 길이 없다** — + * 로고가 '/' 를 걸고 있어도 눌리는 순간 목록으로 돌아온다. + * "로그인 직후엔 내 사이트로" 는 로그인·가입 화면이 직접 보낸다(LoginPage homePath). + */ + void user; return ; } export const router = createBrowserRouter([ - {path: '/login', element: }, + {path: '/login', element: }, // 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다. {path: '/signup', element: }, diff --git a/solution/frontend/src/components/layout/AppShell.tsx b/solution/frontend/src/components/layout/AppShell.tsx index 990aba0..8fa7763 100644 --- a/solution/frontend/src/components/layout/AppShell.tsx +++ b/solution/frontend/src/components/layout/AppShell.tsx @@ -1,6 +1,6 @@ import type {ComponentType, ReactNode} from 'react'; import {Link, NavLink, useLocation, useNavigate} from 'react-router'; -import {LayoutGrid, LogIn, LogOut, Search, Store, Wand2} from 'lucide-react'; +import {LayoutGrid, LogIn, LogOut, Receipt, Search, Store, Wand2} from 'lucide-react'; import {cn} from '@/lib/utils'; import {userLabel, useAuthStore} from '@/stores/auth'; @@ -25,6 +25,15 @@ export type NavItem = { const OWNER_NAV: NavItem[] = [ {to: '/sites', match: '/sites', label: '내 사이트', icon: Store}, {to: '/builder?new=1', match: '/builder', label: '새 사이트', icon: Wand2}, + /* + * ★ 요금은 **로그인한 뒤에도** 갈 길이 있어야 한다 (2026-09-04, 사장님 지적) + * /pricing 은 살아 있는데 링크가 MarketingShell 과 랜딩에만 있었다. 둘 다 로그인하면 + * 안 보이는 화면이라(로그인하면 / 가 /sites 로 간다), 사장님은 주소를 직접 쳐야 했다. + * 요금은 쓰는 도중에 확인하는 값이지 가입 전에만 보는 값이 아니다. + * ★ 목적지가 MarketingShell(사이드바 없는 문서형)이라 사이드바가 사라진다. 그래도 갇히지 + * 않는다 — 저쪽 헤더가 로그인 상태를 알아보고 [내 사이트] 버튼을 세운다. + */ + {to: '/pricing', match: '/pricing', label: '요금', icon: Receipt}, ]; export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?: NavItem[]}) { @@ -87,7 +96,8 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav? // 눌러도 아무 일이 없는 것처럼 보인다 — 로그인 화면으로 보낸다. onClick={() => { signOut(); - navigate('/login'); + // ★ 로그인 화면이 아니라 랜딩으로. 나간 사람에게 다시 로그인 폼을 들이밀지 않는다. + navigate('/'); }} className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60" > diff --git a/solution/frontend/src/components/layout/MarketingShell.tsx b/solution/frontend/src/components/layout/MarketingShell.tsx index e52aa77..2acf72b 100644 --- a/solution/frontend/src/components/layout/MarketingShell.tsx +++ b/solution/frontend/src/components/layout/MarketingShell.tsx @@ -47,6 +47,11 @@ export function MarketingShell({children}: {children: ReactNode}) {
{/* ★ 이미 사이트를 가진 사장님에게 [로그인] 을 다시 보여주지 않는다 — 갈 곳은 내 사이트다. */} + {/* + ★ [무료로 만들기] 를 뺐다 (2026-09-04, 사장님 지시) + 히어로의 큰 입력 카드가 이미 그 자리다 — 시작하는 문이 한 화면에 둘이면 + 어느 쪽이 진짜인지 고르게 만든다. 헤더는 로그인만 받는다. + */} {user ? ( ) : ( - <> - {/* 아임웹처럼 둘 다 버튼이다 — 로그인만 맨 텍스트면 눌리는 것으로 안 보인다. */} - - 로그인 - - - 무료로 만들기 - - + + 로그인 + )}
diff --git a/solution/frontend/src/pages/LoginPage.tsx b/solution/frontend/src/pages/LoginPage.tsx index 863eb26..08478b3 100644 --- a/solution/frontend/src/pages/LoginPage.tsx +++ b/solution/frontend/src/pages/LoginPage.tsx @@ -17,7 +17,21 @@ import {useAuthStore} from '@/stores/auth'; * 우리가 만들어 준다. 가입 링크를 두 곳에 다 두면 admin 라우터에 없는 `/signup` 으로 보내 * 404 가 난다(admin/src/app/router.tsx 에 그 경로는 없다). */ -export function LoginPage({selfServe = true}: {selfServe?: boolean}) { +export function LoginPage({ + selfServe = true, + homePath = '/', +}: { + selfServe?: boolean; + /** + * 로그인 직후 도착지. + * + * ★ 예전엔 '/' 로 보내고 각 앱의 '/' 리다이렉트가 알아서 가르게 했다. 그런데 사장님 앱에서 + * 그 관문이 **로그인한 사람에게 랜딩을 영영 못 보게** 만들었다 — 로고를 눌러도 '/' 가 + * /sites 로 튕긴다(사장님 지적). 관문을 걷고 도착지를 여기서 받는다. + * ★ 기본값은 '/' 다. 내부 운영 앱은 자기 '/' 가 사업장 목록으로 보내므로 그대로 맞는다. + */ + homePath?: string; +}) { const navigate = useNavigate(); const location = useLocation(); const user = useAuthStore((s) => s.user); @@ -28,9 +42,8 @@ export function LoginPage({selfServe = true}: {selfServe?: boolean}) { const [password, setPassword] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); - // ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록) - // 각 라우터의 '/' 리다이렉트가 이미 그걸 안다. 여기 경로를 박으면 한쪽에서 404 다. - const from = (location.state as {from?: string} | null)?.from ?? '/'; + // 원래 가려던 곳이 있으면 그리로, 없으면 앱이 준 홈으로. + const from = (location.state as {from?: string} | null)?.from ?? homePath; if (user) return ; @@ -82,11 +95,10 @@ export function LoginPage({selfServe = true}: {selfServe?: boolean}) { className="w-full max-w-sm space-y-4 rounded-2xl border border-border bg-card p-7" >
- Web4Ai + {/* 로고는 어디서든 랜딩으로 돌아가는 문이다. 로그인 화면에서 나갈 길이 여기뿐이었다. */} + + Web4Ai +

{selfServe ? '로그인' : '관리자'}

diff --git a/solution/frontend/src/pages/SignupPage.tsx b/solution/frontend/src/pages/SignupPage.tsx index 4d2691b..0ec2026 100644 --- a/solution/frontend/src/pages/SignupPage.tsx +++ b/solution/frontend/src/pages/SignupPage.tsx @@ -35,7 +35,7 @@ export function SignupPage() { }); const [isSubmitting, setIsSubmitting] = useState(false); - if (user) return ; + if (user) return ; const set = (key: keyof typeof form) => (e: {target: {value: string}}) => setForm((prev) => ({...prev, [key]: e.target.value})); @@ -76,7 +76,7 @@ export function SignupPage() { return; } notify.success('가입이 완료되었습니다.'); - navigate('/', {replace: true}); + navigate('/sites', {replace: true}); } catch (error) { notifyApiError(error, '가입하지 못했습니다.'); } finally { @@ -97,7 +97,7 @@ export function SignupPage() { notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.'); return; } - navigate('/', {replace: true}); + navigate('/sites', {replace: true}); } catch (error) { notifyApiError(error, '구글로 가입하지 못했습니다.'); } finally { @@ -112,7 +112,10 @@ export function SignupPage() { className="w-full max-w-sm space-y-4 rounded-2xl border border-border bg-card p-7" >
- Web4Ai + {/* 로고는 어디서든 랜딩으로 돌아가는 문이다. */} + + Web4Ai +

회원가입

내 가게 사이트를 만들려면 계정이 필요합니다.

From 34e5932fbcbd6c94abd88d6d8eaf72401ef2fe31 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 7 Sep 2026 09:07:36 +0900 Subject: [PATCH 02/18] =?UTF-8?q?[fix]=20solution/frontend,site:=20?= =?UTF-8?q?=EB=9E=9C=EB=94=A9=20=EC=83=89=EC=9D=B8=20=ED=95=B4=EC=A0=9C=20?= =?UTF-8?q?+=20=EC=82=AC=EC=9D=B4=ED=8A=B8=EB=A7=B5=20URL=20=EC=9D=84=20ca?= =?UTF-8?q?nonical=20=EA=B3=BC=20=EC=9D=BC=EC=B9=98=20=E2=80=94=20?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=EB=9F=AC=EC=9D=98=20=EC=9C=A0=EC=9D=BC?= =?UTF-8?q?=ED=95=9C=20=EB=AC=B8=EC=9D=84=20=EC=97=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이 호스트에는 들어오는 링크가 없다. 크롤러가 발행 사이트를 찾는 경로는 사이트맵 하나뿐인데 그 문이 두 군데서 막혀 있었다. - 랜딩('/')에 `noindex, nofollow` 가 박혀 있었다. 관리자 화면이라고 보고 걸어 둔 것인데(7a6fee7 로 '/' 관문이 사라져 이제는 랜딩이다), nofollow 때문에 랜딩→발행 사이트로 이어지는 발견 경로까지 함께 죽어 있었다. - 사이트맵이 `/s//` 를 담는데 페이지 canonical 은 `/s/` 다 (shared/lib/slug.ts publishUrl). 서치콘솔은 제출 URL 을 전부 "대체 페이지"로 분류한다 — 색인은 되는데 제출분 0건으로 보이는, 조용히 틀리는 종류다. - frontend/index.html: robots 를 index,follow 로. description·canonical·og 추가. 호스트는 적지 않고 Vite 가 빌드 때 `%VITE_PUBLISH_HOST%` 를 치환한다 (compose 가 루트 SITE_PUBLIC_HOST 를 흘려보낸다) — 두 곳에 적으면 갈라진다 - site/scripts/prerender.ts: 사이트맵 loc 의 끝 슬래시 제거 + 오리진 루트를 첫 항목으로 남은 것: 랜딩 body 가 빈 SPA 셸이라 렌더링에 기댄다. 랜딩 프리렌더는 별건이다. 검증: tsc(solution/site) 통과 · vite build 로 %VITE_PUBLISH_HOST% 치환 확인 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj --- solution/frontend/index.html | 21 +++++++++++++++++++-- solution/site/scripts/prerender.ts | 15 ++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/solution/frontend/index.html b/solution/frontend/index.html index f5ab125..6c3f519 100644 --- a/solution/frontend/index.html +++ b/solution/frontend/index.html @@ -3,8 +3,25 @@ - - + + + + + + + + + + + + diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index a42c424..8c6df86 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -26,6 +26,7 @@ import { renderLlmsTxt, renderRootRobotsTxt, renderSiteUrlset, + type SiteEntry, verifyGeo, verifyJsonLd, } from '@/seo'; @@ -509,21 +510,29 @@ function writeRootMachineFiles(outRoot: string, origin: string) { const sitesDir = join(outRoot, SITE_DIR); if (!existsSync(sitesDir)) return; - const entries = readdirSync(sitesDir, {withFileTypes: true}) + const entries: SiteEntry[] = readdirSync(sitesDir, {withFileTypes: true}) .filter((entry) => entry.isDirectory()) .map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')})) // index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. 사이트맵에 넣지 않는다. .filter((entry) => existsSync(entry.file)) .map((entry) => ({ - loc: joinUrl(origin, SITE_DIR, entry.slug) + '/', + // ★ 끝 슬래시를 붙이지 않는다. 페이지의 canonical 은 `/s/` 다(shared/lib/slug.ts + // publishUrl). 사이트맵이 `/s//` 로 어긋나 있던 동안 서치콘솔은 제출한 URL 을 + // 전부 "대체 페이지(적절한 표준 태그가 있음)" 로 분류했다 — 색인은 되는데 제출분은 + // 0건으로 보이는, 눈으로 원인을 못 찾는 종류다. + loc: joinUrl(origin, SITE_DIR, entry.slug), // 페이지는 그 사이트를 구울 때마다 다시 쓰인다 — 파일 mtime 이 곧 마지막 발행 시각이다. lastmod: statSync(entry.file).mtime.toISOString(), })) .sort((a, b) => a.loc.localeCompare(b.loc)); + // ★ 오리진 루트(랜딩)도 담는다. 이 호스트에 들어오는 링크가 없어 크롤러의 유일한 문이 + // 사이트맵인데, 정작 그 문을 여는 첫 페이지가 빠져 있었다. + entries.unshift({loc: origin + '/'}); + writeFileSync(join(outRoot, 'robots.txt'), renderRootRobotsTxt(origin), 'utf-8'); writeFileSync(join(outRoot, 'sitemap.xml'), renderSiteUrlset(entries), 'utf-8'); - console.log(` ✓ 루트 robots.txt · sitemap.xml (사이트 ${entries.length}개)`); + console.log(` ✓ 루트 robots.txt · sitemap.xml (랜딩 + 사이트 ${entries.length - 1}개)`); writeIndexNowKey(outRoot); } From 9aa93b282cd51a5e2d19b33e6c4b08a40ac90367 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 7 Sep 2026 09:45:00 +0900 Subject: [PATCH 03/18] =?UTF-8?q?[feat]=20solution/site,nginx:=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=EB=B3=B8=20=EB=AA=A9=EB=A1=9D=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=99=80=20=EB=A3=A8=ED=8A=B8=20llms.txt=20=E2=80=94?= =?UTF-8?q?=20=ED=81=AC=EB=A1=A4=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20=EB=91=98?= =?UTF-8?q?=EB=A1=9C=20=EB=8A=98=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서치콘솔 URL 검사(2026-09-07, /s/stay): "참조 페이지: 감지된 페이지 없음". 색인은 됐는데 이 호스트의 어떤 페이지도 발행본을 가리키지 않아, 크롤러가 발행본에 닿는 길이 사이트맵 하나뿐이었다. 사이트맵은 "이런 주소가 있다"만 말하고 볼 가치가 있는지는 말하지 않는다 — 그래서 색인은 되고 순위는 0인 상태가 됐다. 랜딩의 쇼케이스는 API 를 fetch 해 그리는 클라이언트 렌더라(ShowcaseGrid.tsx) JS 를 실행하지 않는 크롤러에게는 없는 링크다. 그래서 정적 HTML 로 따로 굽는다. - seo/directory.ts: `/s/` 목록 페이지(CollectionPage + ItemList LD)와 루트 llms.txt. 목록의 제목은 payload 가 아니라 **구운 index.html 의 ** 에서 읽는다 — 발행은 바뀐 사이트 하나만 굽기 때문에 payload 로 만들면 나머지가 슬러그로 떨어진다 - prerender.ts: 사이트맵에 랜딩과 목록 페이지를 추가. 목록 주소는 끝 슬래시가 있어야 한다 — nginx 의 `location ^~ /s/` 가 슬래시로만 잡고, 없으면 사장님 앱으로 떨어진다 - nginx: `location ^~ /s/` 에 `index index.html`. try_files 첫 인자가 끝 슬래시면 nginx 가 디렉토리 검사로 읽고 거기서 멈춰 403 이 된다(=404 로도 안 떨어진다) ★ 루트 llms.txt 의 기대치: 구글은 안 쓴다고 공식 확인했고(2025-07 Illyes) 크롤러 트래픽으로도 거의 안 잡힌다(90일 5억 방문 중 408건). 두는 이유는 에이전트 경로 하나다 — 사용자가 AI 에게 "이 사이트 봐줘" 할 때의 fetch 는 봇 집계에 안 잡힌다. 비용이 함수 하나라 채택되면 이미 있는 쪽을 택했다. 발행본별 llms.txt 는 그대로다. 검증: tsc(solution/site) 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj --- nginx/site.conf.example | 4 + solution/site/scripts/prerender.ts | 29 ++++- solution/site/src/seo/directory.ts | 174 +++++++++++++++++++++++++++++ solution/site/src/seo/index.ts | 1 + 4 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 solution/site/src/seo/directory.ts diff --git a/nginx/site.conf.example b/nginx/site.conf.example index 273b729..9f31085 100644 --- a/nginx/site.conf.example +++ b/nginx/site.conf.example @@ -46,6 +46,10 @@ server { # ── 발행 사이트 ──────────────────────────────────────────── # ^~ 로 잡아 아래 정규식 location 들이 끼어들지 못하게 한다. location ^~ /s/ { + # ★ `/s/` 자체(발행본 목록 페이지)를 위해 필요하다. try_files 의 첫 인자 `$uri` 가 + # 끝 슬래시면 nginx 는 **디렉토리 검사**로 읽고, 디렉토리가 있으면 거기서 멈춘다 — + # index 지시자가 없으면 그 순간 403 이다(=404 로도 안 떨어진다). + index index.html; # $uri/ 를 거치면 nginx 가 끝 슬래시로 301 을 내보낸다. 크롤러가 리다이렉트를 # 한 번 더 타야 하므로 index.html 을 바로 준다. try_files $uri $uri/index.html =404; diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index 8c6df86..e8ab11d 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -24,8 +24,11 @@ import { homeMeta, renderHead, renderLlmsTxt, + renderRootLlmsTxt, renderRootRobotsTxt, + renderSiteIndex, renderSiteUrlset, + type DirectoryEntry, type SiteEntry, verifyGeo, verifyJsonLd, @@ -510,7 +513,7 @@ function writeRootMachineFiles(outRoot: string, origin: string) { const sitesDir = join(outRoot, SITE_DIR); if (!existsSync(sitesDir)) return; - const entries: SiteEntry[] = readdirSync(sitesDir, {withFileTypes: true}) + const sites: DirectoryEntry[] = readdirSync(sitesDir, {withFileTypes: true}) .filter((entry) => entry.isDirectory()) .map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')})) // index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. 사이트맵에 넣지 않는다. @@ -521,21 +524,37 @@ function writeRootMachineFiles(outRoot: string, origin: string) { // 전부 "대체 페이지(적절한 표준 태그가 있음)" 로 분류했다 — 색인은 되는데 제출분은 // 0건으로 보이는, 눈으로 원인을 못 찾는 종류다. loc: joinUrl(origin, SITE_DIR, entry.slug), + // 제목은 구운 HTML 에서 읽는다. payload 에서 가져오면 이번 실행분만 이름이 있고 + // 나머지는 슬러그로 떨어진다 — 발행은 바뀐 사이트 하나만 굽기 때문이다. + title: readTitle(entry.file) || entry.slug, // 페이지는 그 사이트를 구울 때마다 다시 쓰인다 — 파일 mtime 이 곧 마지막 발행 시각이다. lastmod: statSync(entry.file).mtime.toISOString(), })) .sort((a, b) => a.loc.localeCompare(b.loc)); - // ★ 오리진 루트(랜딩)도 담는다. 이 호스트에 들어오는 링크가 없어 크롤러의 유일한 문이 - // 사이트맵인데, 정작 그 문을 여는 첫 페이지가 빠져 있었다. - entries.unshift({loc: origin + '/'}); + // ★ `/s/` 목록 페이지. 크롤러가 발행본에 닿는 두 번째 경로다 — + // 사이트맵만 있을 때 서치콘솔은 "참조 페이지: 감지된 페이지 없음" 이라고 답했다. + // ★ 주소에 끝 슬래시가 있어야 한다 — nginx 의 `location ^~ /s/` 가 슬래시로만 잡는다. + const indexUrl = joinUrl(origin, SITE_DIR) + '/'; + writeFileSync(join(sitesDir, 'index.html'), renderSiteIndex(origin, indexUrl, sites), 'utf-8'); + + // 사이트맵에는 랜딩·목록 페이지도 담는다. 랜딩은 이 호스트의 첫 페이지이고, + // 목록은 발행본 전부로 이어지는 허브다 — 둘 다 크롤러가 먼저 열어야 하는 자리다. + const entries: SiteEntry[] = [{loc: origin + '/'}, {loc: indexUrl}, ...sites]; writeFileSync(join(outRoot, 'robots.txt'), renderRootRobotsTxt(origin), 'utf-8'); writeFileSync(join(outRoot, 'sitemap.xml'), renderSiteUrlset(entries), 'utf-8'); - console.log(` ✓ 루트 robots.txt · sitemap.xml (랜딩 + 사이트 ${entries.length - 1}개)`); + writeFileSync(join(outRoot, 'llms.txt'), renderRootLlmsTxt(origin, indexUrl, sites), 'utf-8'); + console.log(` ✓ 루트 robots.txt · sitemap.xml · llms.txt · /s/ 목록 (사이트 ${sites.length}개)`); writeIndexNowKey(outRoot); } +/** 구운 index.html 에서 <title> 만 꺼낸다. 파서를 붙일 값어치가 없는 한 줄짜리 일이다. */ +function readTitle(file: string): string { + const match = /<title>([^<]*)<\/title>/.exec(readFileSync(file, 'utf-8')); + return match ? match[1].trim() : ''; +} + /** * IndexNow 키 파일 — `https://<host>/<key>.txt` 에 키 문자열만 들어 있다. * diff --git a/solution/site/src/seo/directory.ts b/solution/site/src/seo/directory.ts new file mode 100644 index 0000000..6c87b9d --- /dev/null +++ b/solution/site/src/seo/directory.ts @@ -0,0 +1,174 @@ +import {escapeHtml} from './head'; + +export interface DirectoryEntry { + /** 발행본 주소. 페이지 canonical 과 **같은 형태**여야 한다(끝 슬래시 없음). */ + loc: string; + /** 구운 index.html 의 <title>. 디스크에 있는 것이 곧 정답이다. */ + title: string; + /** 마지막 발행 시각(ISO). */ + lastmod?: string; +} + +/** + * `/s/` 발행 사이트 목록 페이지. + * + * ★ 왜 필요한가 — 크롤러가 발행본에 닿는 경로가 사이트맵 **하나뿐**이었다. + * 실측(2026-09-07 서치콘솔 URL 검사, /s/stay): "참조 페이지: 감지된 페이지 없음". + * 사이트맵은 "이런 주소가 있다"만 말하고 그 페이지가 왜 볼 가치가 있는지는 말하지 않는다. + * 링크는 둘 다 한다 — 그래서 색인은 되는데 순위가 0인 상태를 링크가 푼다. + * + * ★ 왜 자바스크립트를 쓰지 않는가 — 랜딩(`/`)의 쇼케이스는 API 를 fetch 해서 그리는 + * 클라이언트 렌더다(ShowcaseGrid.tsx). JS 를 실행하지 않는 크롤러에게 그 링크는 없는 것과 + * 같다. 이 페이지는 링크가 존재하는 것 자체가 목적이므로 정적 HTML 로 굽는다. + */ +export function renderSiteIndex( + origin: string, + /** 목록 페이지 자신의 주소. **끝 슬래시가 있어야 한다** — nginx 가 `^~ /s/` 로만 발행본 + * 디렉토리를 잡고, 슬래시 없는 `/s` 는 사장님 앱(SPA)으로 떨어진다(nginx/site.conf). */ + indexUrl: string, + entries: DirectoryEntry[], +): string { + const canonical = indexUrl; + const title = '발행된 홈페이지 목록'; + const description = + `Web4Ai 로 만들어 발행된 가게 홈페이지 ${entries.length}곳입니다. ` + + '각 페이지는 사업자가 확인한 정보만 담고 있습니다.'; + + const items = entries + .map((entry, index) => + [ + ' {', + ' "@type": "ListItem",', + ` "position": ${index + 1},`, + ` "url": ${JSON.stringify(entry.loc)},`, + ` "name": ${JSON.stringify(entry.title)}`, + ' }', + ].join('\n'), + ) + .join(',\n'); + + const cards = entries + .map((entry) => + [ + ' <li>', + ` <a href="${escapeHtml(entry.loc)}">`, + ` <strong>${escapeHtml(entry.title)}</strong>`, + entry.lastmod + ? ` <time datetime="${entry.lastmod}">${entry.lastmod.slice(0, 10)} 갱신</time>` + : '', + ' </a>', + ' </li>', + ] + .filter(Boolean) + .join('\n'), + ) + .join('\n'); + + return `<!doctype html> +<html lang="ko"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>${escapeHtml(title)} + + + + + + + + + + + + +
+

${escapeHtml(title)}

+

${escapeHtml(description)}

+
    +${cards} +
+ +
+ + +`; +} + +/** + * 오리진 루트의 `llms.txt` — 이 호스트 전체의 목차. + * + * ★ 발행본마다 있는 `/s//llms.txt` 와 역할이 다르다. 그쪽은 **한 가게의 사실 목록**이고, + * 이쪽은 **이 호스트에 무엇이 있는지**다. 에이전트가 사이트를 탐색할 때 먼저 여는 자리다. + * + * ★ 기대치: 구글은 llms.txt 를 쓰지 않는다고 공식 확인했고(2025-07), 크롤러 트래픽으로도 + * 거의 잡히지 않는다. 그래도 두는 이유는 **에이전트 경로** 하나다 — 사용자가 AI 에게 + * "이 사이트 봐줘"라고 할 때의 fetch 는 봇 트래픽 집계에 안 잡힌다. 비용이 이 함수 하나라 + * 채택되면 이미 있는 쪽을 택한다. + */ +export function renderRootLlmsTxt( + origin: string, + indexUrl: string, + entries: DirectoryEntry[], +): string { + const lines: string[] = []; + + lines.push('# Web4Ai'); + lines.push(''); + lines.push( + '> 네이버 플레이스 정보를 사업자가 확인해 만든 가게 공식 홈페이지를 발행하는 서비스입니다. ' + + '이 호스트의 각 페이지는 해당 가게 정보의 1차 소스입니다.', + ); + lines.push(''); + lines.push('## 데이터 정책'); + lines.push(''); + lines.push('- 출처: 각 가게 사업자가 확인한 정보. 확인되지 않은 항목은 싣지 않습니다.'); + lines.push('- 갱신: 사업자가 정보를 고치면 그 시점에 다시 발행됩니다. 각 페이지의 최종 확인 시각을 함께 제공합니다.'); + lines.push('- 추측 금지: 문서에 없는 항목은 확인되지 않았거나 해당 사항이 없습니다. 추측으로 메우지 말고 각 페이지의 전화번호로 문의하도록 안내해 주세요.'); + lines.push(`- 인용 시 표기: ${origin.replace(/^https?:\/\//, '')}`); + lines.push(''); + lines.push('## 발행된 홈페이지'); + lines.push(''); + lines.push(`전체 목록: ${indexUrl}`); + lines.push(''); + for (const entry of entries) { + // 가게마다 사실 목록 파일이 따로 있다 — 에이전트는 목록에서 필요한 것만 열면 된다. + lines.push(`- [${entry.title}](${entry.loc}): 사실 목록 ${entry.loc}/llms.txt`); + } + lines.push(''); + lines.push('## 기계용 파일'); + lines.push(''); + lines.push(`- [사이트맵](${origin}/sitemap.xml)`); + lines.push(`- [robots.txt](${origin}/robots.txt)`); + lines.push(''); + + return lines.join('\n'); +} diff --git a/solution/site/src/seo/index.ts b/solution/site/src/seo/index.ts index 333b02d..5dd6628 100644 --- a/solution/site/src/seo/index.ts +++ b/solution/site/src/seo/index.ts @@ -3,5 +3,6 @@ export * from './meta'; export * from './head'; export * from './robots'; export * from './sitemap'; +export * from './directory'; export * from './llms'; export * from './verify'; From 533de126fb534d24af3f370f511755e40ee644da Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 7 Sep 2026 11:30:27 +0900 Subject: [PATCH 04/18] =?UTF-8?q?[feat]=20solution/frontend,nginx:=20?= =?UTF-8?q?=EB=9E=9C=EB=94=A9=C2=B7=EC=9A=94=EA=B8=88=C2=B7=EC=82=AC?= =?UTF-8?q?=EB=A1=80=EB=A5=BC=20=ED=94=84=EB=A6=AC=EB=A0=8C=EB=8D=94=20?= =?UTF-8?q?=E2=80=94=20=ED=81=AC=EB=A1=A4=EB=9F=AC=EA=B0=80=20=EB=B9=88=20?= =?UTF-8?q?=EC=A2=85=EC=9D=B4=EB=A5=BC=20=EB=B0=9B=EB=8D=98=20=EA=B2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실측(2026-09-07): `curl /` 가 3,021바이트에 본문 0자·`` 0개였다. 같은 호스트의 발행본은 48,072바이트다. 구글은 JS 를 실행하지만 **렌더링 큐가 따로** 돌고 신규 도메인은 뒤로 밀린다 — 그동안 색인에는 "제목만 있고 내용 없는 페이지"로 들어가 있다. 서치콘솔이 "URL이 Google에 등록되어 있음"이라고 답하면서도 브랜드명 검색에조차 안 걸리던 이유다. 스크립트를 새로 짜지 않았다. react-router 7.17 에 프리렌더가 내장돼 있고 `ssr: false` 와 함께 쓰면 런타임 Node 서버 없이 지정한 경로만 HTML 로 굽는다 — 나머지는 지금까지처럼 SPA 폴백이다. 배포 구조가 그대로다. - react-router.config.ts: `ssr:false` + `prerender: ['/', '/pricing', '/showcase']`. 로그인 뒤에만 의미가 있는 화면은 굽지 않는다(구울 내용이 사용자별이다) - src/root.tsx · src/routes.ts: 예전 index.html + app/router.tsx 가 하던 일. 가드는 페이지마다 감싸지 않고 RequireAuthLayout 레이아웃 라우트 하나로 모았다 - 랜딩·요금·사례에 meta export: 제목을 브랜드가 아니라 **검색어**로 시작하게 바꿨다. 예전 제목("Web4Ai · AI 웹 빌더")에는 사람이 치는 말이 한 단어도 없었다. 랜딩에 Organization JSON-LD 추가 — 발행본에는 있는데 정작 랜딩엔 없었다 - src/lib/site.ts: 발행 호스트의 단일 출처. 모듈 최상위의 `window.location` 폴백을 전부 걷었다 — 서버 번들은 라우트를 한 파일로 묶어서 프리렌더 대상이 아닌 화면의 최상위 코드도 빌드 때 실행된다(실측: BuilderPage 에서 빌드가 죽었다) - LoginPage: homePath 기본값 `/` → `/sites`. 예전엔 router.tsx 가 넘기던 값이라 라우트 모듈로 옮기면서 그대로 두면 로그인 후 랜딩으로 갔다 - nginx: SPA 폴백을 `/index.html` → `/__spa-fallback.html`. 프리렌더 뒤로 `/index.html` 은 **랜딩이 구워진 파일**이라, 그리로 넘기면 `/builder` 에 랜딩 HTML 이 내려가고 클라이언트가 다른 주소로 하이드레이트한다 - nginx/Dockerfile: 산출물이 `dist` → `build/client`. 경로가 어긋나면 COPY 가 조용히 빈 디렉토리를 만들고 컨테이너는 정상으로 뜬다 - site/seo/robots.ts: `/builder` `/login` `/signup` `/sites` `/account` Disallow. 이 경로들은 빈 SPA 폴백을 받는다 — 긁히면 호스트 전체에 저품질 신호가 쌓인다 검증: tsc·eslint·react-router build 통과. 랜딩 3,021B → 21,799B, 본문 1,278자, 링크 6개. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019fteiJNvAEbTnUKq8fSqoj --- .gitignore | 3 + nginx/Dockerfile | 4 +- nginx/site.conf.example | 12 +- package-lock.json | 514 +++++++++++++++++- solution/frontend/index.html | 45 -- solution/frontend/package.json | 11 +- solution/frontend/react-router.config.ts | 24 + solution/frontend/src/app/main.tsx | 14 - solution/frontend/src/app/router.tsx | 98 ---- .../components/layout/RequireAuthLayout.tsx | 14 + .../src/features/builder/CanvasView.tsx | 5 +- .../src/features/marketing/ShowcaseGrid.tsx | 12 +- .../src/features/publish/PublishModal.tsx | 5 +- solution/frontend/src/lib/site.ts | 15 + solution/frontend/src/pages/AccountPage.tsx | 3 + solution/frontend/src/pages/BuilderPage.tsx | 8 +- .../frontend/src/pages/DevShowcasePage.tsx | 3 + solution/frontend/src/pages/LandingPage.tsx | 41 ++ solution/frontend/src/pages/LoginPage.tsx | 6 +- solution/frontend/src/pages/NotFoundPage.tsx | 3 + solution/frontend/src/pages/PricingPage.tsx | 17 + solution/frontend/src/pages/ShowcasePage.tsx | 17 + solution/frontend/src/pages/SignupPage.tsx | 3 + solution/frontend/src/pages/SitesPage.tsx | 8 +- solution/frontend/src/root.tsx | 82 +++ solution/frontend/src/routes.ts | 42 ++ solution/frontend/vite.config.ts | 9 +- solution/site/src/seo/robots.ts | 15 +- 28 files changed, 850 insertions(+), 183 deletions(-) delete mode 100644 solution/frontend/index.html create mode 100644 solution/frontend/react-router.config.ts delete mode 100644 solution/frontend/src/app/main.tsx delete mode 100644 solution/frontend/src/app/router.tsx create mode 100644 solution/frontend/src/components/layout/RequireAuthLayout.tsx create mode 100644 solution/frontend/src/lib/site.ts create mode 100644 solution/frontend/src/root.tsx create mode 100644 solution/frontend/src/routes.ts diff --git a/.gitignore b/.gitignore index 8e202b1..2d394da 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ solution/backend/openapi.json # 여기 생긴다. 어느 쪽이든 payload 로 다시 굽는 재생성물이라 git 이 관리할 대상이 아니다. solution/site/out/ solution/site/dist/ +# React Router 프레임워크 모드(solution/frontend)의 산출물 +build/ +.react-router/ admin/dist/ # nginx 설정: 서버마다 다르므로 실제 파일은 커밋하지 않는다. 템플릿만 커밋한다. diff --git a/nginx/Dockerfile b/nginx/Dockerfile index f370b72..0913c69 100644 --- a/nginx/Dockerfile +++ b/nginx/Dockerfile @@ -43,4 +43,6 @@ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \ RUN npm run build -w @o2o/frontend FROM nginx:alpine -COPY --from=build /app/solution/frontend/dist /srv/app +# ★ 프레임워크 모드(React Router)의 산출물은 `build/client` 다 — 예전 `dist` 가 아니다. +# 경로가 어긋나면 COPY 가 조용히 빈 디렉토리를 만들고 컨테이너는 정상으로 뜬다. +COPY --from=build /app/solution/frontend/build/client /srv/app diff --git a/nginx/site.conf.example b/nginx/site.conf.example index 9f31085..16b79e3 100644 --- a/nginx/site.conf.example +++ b/nginx/site.conf.example @@ -121,12 +121,16 @@ server { try_files $uri =404; } - # SPA 다. 없는 경로는 index.html 로 넘겨 클라이언트 라우터가 받게 한다. - # ★ index.html 은 캐시하지 않는다 — 여기에 번들 해시가 박혀 있어서, 캐시되면 - # 새로 배포해도 브라우저가 옛 번들 주소를 계속 부른다(404 → 흰 화면). + # ★ 폴백은 `/index.html` 이 아니라 `__spa-fallback.html` 이다. + # 프리렌더를 켠 뒤로 `/index.html` 은 **랜딩이 구워진 파일**이다 — 여기로 넘기면 + # `/builder` 를 열었는데 랜딩 HTML 이 내려가고, 클라이언트 라우터는 다른 주소로 + # 하이드레이트한다. 화면은 뜨는데 한 번 깜빡이고 마크업이 어긋나는 종류다. + # 구워진 경로(`/` `/pricing` `/showcase`)는 그 앞의 `$uri/index.html` 이 먼저 잡는다. + # ★ HTML 은 캐시하지 않는다 — 번들 해시가 박혀 있어서, 캐시되면 새로 배포해도 + # 브라우저가 옛 번들 주소를 계속 부른다(404 → 흰 화면). location / { root /srv/app; - try_files $uri $uri/index.html /index.html; + try_files $uri $uri/index.html /__spa-fallback.html; add_header Cache-Control "no-cache"; } diff --git a/package-lock.json b/package-lock.json index 032438f..d6075fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -215,6 +215,19 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-compilation-targets": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", @@ -232,6 +245,28 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/helper-globals": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", @@ -242,6 +277,20 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", @@ -274,6 +323,19 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-plugin-utils": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", @@ -284,6 +346,38 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -344,6 +438,55 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-react-jsx-self": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", @@ -376,6 +519,46 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -1425,6 +1608,12 @@ "jsep": "^0.4.0||^1.0.0" } }, + "node_modules/@mjackson/node-fetch-server": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz", + "integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==", + "license": "MIT" + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -1631,6 +1820,153 @@ "openapi3-ts": "4.5.0" } }, + "node_modules/@react-router/dev": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.18.3.tgz", + "integrity": "sha512-smLBdktEcLw1BgjaeWZG+TDRpmK9Mry4DzgNv4q556/Kq9qDo9Lfxu9Gp4/4BGOctFQG2UuyvxOnOhz0tEyzWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.7", + "@babel/generator": "^7.27.5", + "@babel/parser": "^7.27.7", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/preset-typescript": "^7.27.1", + "@babel/traverse": "^7.27.7", + "@babel/types": "^7.27.7", + "@react-router/node": "7.18.3", + "@remix-run/node-fetch-server": "^0.13.0", + "arg": "^5.0.1", + "babel-dead-code-elimination": "^1.0.6", + "chokidar": "^4.0.0", + "dedent": "^1.5.3", + "es-module-lexer": "^1.3.1", + "exit-hook": "2.2.1", + "isbot": "^5.1.11", + "jsesc": "3.0.2", + "lodash": "^4.17.21", + "p-map": "^7.0.3", + "pathe": "^1.1.2", + "picocolors": "^1.1.1", + "pkg-types": "^2.3.0", + "prettier": "^3.6.2", + "react-refresh": "^0.14.0", + "semver": "^7.3.7", + "tinyglobby": "^0.2.14", + "valibot": "^1.2.0", + "vite-node": "^3.2.2" + }, + "bin": { + "react-router": "bin.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@react-router/serve": "^7.18.3", + "@vitejs/plugin-rsc": "~0.5.21", + "react-router": "^7.18.3", + "react-server-dom-webpack": "^19.2.3", + "typescript": "^5.1.0 || ^6.0.0", + "vite": "^5.1.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "wrangler": "^3.28.2 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@react-router/serve": { + "optional": true + }, + "@vitejs/plugin-rsc": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + }, + "typescript": { + "optional": true + }, + "wrangler": { + "optional": true + } + } + }, + "node_modules/@react-router/dev/node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@react-router/dev/node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@react-router/dev/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@react-router/dev/node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@react-router/dev/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-router/node": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.18.3.tgz", + "integrity": "sha512-wIBFSsmp+uA/F2MEHN1BxFoWAhh9rdIH/Zd39KYyLhZSeb35YlRi8ARtOMDYj7EqhhZfkoetf/SEmYywK3nUkA==", + "license": "MIT", + "dependencies": { + "@mjackson/node-fetch-server": "^0.2.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react-router": "7.18.3", + "typescript": "^5.1.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@remix-run/node-fetch-server": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/@remix-run/node-fetch-server/-/node-fetch-server-0.13.3.tgz", + "integrity": "sha512-UfjOXed/DQteaM5VyTfqTeGpHwyL2J5aoRGY6cydip4tt1ehNNeSwuXCC7AEGE0RWBs/7bgKxYkL/B/+UDe4AA==", + "dev": true, + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -3376,6 +3712,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3478,6 +3821,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/babel-dead-code-elimination": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", + "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3556,6 +3912,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -3767,6 +4133,13 @@ "dev": true, "license": "MIT" }, + "node_modules/confbox": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", + "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3881,6 +4254,21 @@ } } }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4545,6 +4933,19 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -4565,6 +4966,13 @@ "node": ">=16.9.0" } }, + "node_modules/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5640,6 +6048,15 @@ "dev": true, "license": "MIT" }, + "node_modules/isbot": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.2.tgz", + "integrity": "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -6826,6 +7243,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.7.tgz", + "integrity": "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6895,6 +7325,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkg-types": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.2.tgz", + "integrity": "sha512-v0sVXzj7oPGysr543YYZLYbcJNJsKikSsp/fFzoxQ12ewY3ZZr7oCPC8y7OlmxfYB3QPvriXmuPD8KZggE1vqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.3.0", + "exsolve": "^1.1.1", + "pathe": "^2.0.3" + } + }, "node_modules/pony-cause": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz", @@ -6953,6 +7395,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7042,9 +7500,9 @@ } }, "node_modules/react-router": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", - "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -8137,7 +8595,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -8272,6 +8730,21 @@ "node": ">= 4" } }, + "node_modules/valibot": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/validator": { "version": "13.15.23", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz", @@ -8356,6 +8829,36 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -8791,10 +9294,12 @@ "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.4.0", "@o2o/shared": "*", + "@react-router/node": "^7.18.3", "@tailwindcss/vite": "^4.1.14", "@tanstack/react-query": "^5.62.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "isbot": "^5", "lucide-react": "^0.546.0", "motion": "^12.23.24", "react": "^19.0.1", @@ -8808,6 +9313,7 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@react-router/dev": "^7.18.3", "@types/node": "^22.14.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", diff --git a/solution/frontend/index.html b/solution/frontend/index.html deleted file mode 100644 index 6c3f519..0000000 --- a/solution/frontend/index.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - Web4Ai · AI 웹 빌더 - - -
- - - diff --git a/solution/frontend/package.json b/solution/frontend/package.json index 0ffa08b..01b234a 100644 --- a/solution/frontend/package.json +++ b/solution/frontend/package.json @@ -4,11 +4,11 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite --port=3000 --host=0.0.0.0", - "build": "tsc --noEmit && eslint src && vite build", + "dev": "react-router dev --port=3000 --host=0.0.0.0", + "build": "react-router typegen && tsc --noEmit && eslint src && react-router build", "preview": "vite preview", - "clean": "rm -rf dist", - "lint": "tsc --noEmit && eslint src", + "clean": "rm -rf build .react-router", + "lint": "react-router typegen && tsc --noEmit && eslint src", "orval": "orval --config ./orval.config.ts" }, "dependencies": { @@ -17,10 +17,12 @@ "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.4.0", "@o2o/shared": "*", + "@react-router/node": "^7.18.3", "@tailwindcss/vite": "^4.1.14", "@tanstack/react-query": "^5.62.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "isbot": "^5", "lucide-react": "^0.546.0", "motion": "^12.23.24", "react": "^19.0.1", @@ -34,6 +36,7 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@react-router/dev": "^7.18.3", "@types/node": "^22.14.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", diff --git a/solution/frontend/react-router.config.ts b/solution/frontend/react-router.config.ts new file mode 100644 index 0000000..0273023 --- /dev/null +++ b/solution/frontend/react-router.config.ts @@ -0,0 +1,24 @@ +import type {Config} from '@react-router/dev/config'; + +/** + * 프리렌더 설정. + * + * ★ 왜 하나 — 랜딩은 `
` 만 내보내는 CSR 이었다. 실측(2026-09-07): + * `curl /` 는 3,021바이트에 `
` 0개·본문 0자. 같은 호스트의 발행본은 48,072바이트다. + * 구글은 JS 를 실행하지만 **렌더링 큐가 따로** 돌고 신규 도메인은 뒤로 밀린다 — + * 그동안 색인에는 "제목만 있고 내용 없는 페이지"로 들어가 있다. + * + * ★ `ssr: false` 와 함께 쓴다. 런타임 Node 서버를 두지 않는다는 뜻이다 — + * 아래 목록만 HTML 로 굽고, 나머지(`/builder` `/login` `/sites` `/account`)는 + * 지금까지처럼 SPA 폴백으로 나간다. nginx 설정도 배포 구조도 그대로다. + * + * ★ 로그인 뒤에만 의미가 있는 화면은 굽지 않는다. 구울 내용이 없고(데이터가 사용자별), + * robots.txt 로 막을 대상이기도 하다. + */ +export default { + // 이 레포는 소스가 `src/` 밑이다(기본값 `app/` 이 아니다). + // root.tsx · routes.ts 를 여기서 찾는다. + appDirectory: 'src', + ssr: false, + prerender: ['/', '/pricing', '/showcase'], +} satisfies Config; diff --git a/solution/frontend/src/app/main.tsx b/solution/frontend/src/app/main.tsx deleted file mode 100644 index 867493b..0000000 --- a/solution/frontend/src/app/main.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import {StrictMode} from 'react'; -import {createRoot} from 'react-dom/client'; -import {RouterProvider} from 'react-router'; -import {Providers} from './provider'; -import {router} from './router'; -import '../index.css'; - -createRoot(document.getElementById('root')!).render( - - - - - , -); diff --git a/solution/frontend/src/app/router.tsx b/solution/frontend/src/app/router.tsx deleted file mode 100644 index 2621c72..0000000 --- a/solution/frontend/src/app/router.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import {createBrowserRouter, Navigate} from 'react-router'; -import {Loader2} from 'lucide-react'; -import {AccountPage} from '@/pages/AccountPage'; -import {BuilderPage} from '@/pages/BuilderPage'; -import {DevShowcasePage} from '@/pages/DevShowcasePage'; -import {LandingPage} from '@/pages/LandingPage'; -import {LoginPage} from '@/pages/LoginPage'; -import {NotFoundPage} from '@/pages/NotFoundPage'; -import {PricingPage} from '@/pages/PricingPage'; -import {ShowcasePage} from '@/pages/ShowcasePage'; -import {SignupPage} from '@/pages/SignupPage'; -import {SitesPage} from '@/pages/SitesPage'; -import {RequireAuth} from '@/components/layout/RequireAuth'; -import {useAuthStore} from '@/stores/auth'; - -/** - * 첫 화면은 로그인 여부로 갈린다 — 이미 사이트를 가진 사장님의 용건은 "새로 만들기"가 아니라 - * "내 것 고치기"다. 비로그인은 **랜딩**을 본다. - * - * ★ 예전엔 비로그인을 곧장 위저드로 보냈다. 그러면 이 제품이 무엇을 파는 물건인지 말할 자리가 - * 한 곳도 없다 — 처음 온 사람이 업종 선택 화면부터 만난다. - * ★ 복구가 끝나기 전에 판단하면 새로고침할 때마다 화면이 한 번 번쩍이고 목록으로 튄다. - */ -function Home() { - const isRestoring = useAuthStore((s) => s.isRestoring); - const user = useAuthStore((s) => s.user); - - if (isRestoring) { - return ( -
- -
- ); - } - /* - * ★ 로그인해도 랜딩을 보여 준다 (2026-09-04, 사장님 지적) - * 예전엔 여기서 /sites 로 튕겼다. 그러면 **로그인한 사장님은 랜딩·요금에 갈 길이 없다** — - * 로고가 '/' 를 걸고 있어도 눌리는 순간 목록으로 돌아온다. - * "로그인 직후엔 내 사이트로" 는 로그인·가입 화면이 직접 보낸다(LoginPage homePath). - */ - void user; - return ; -} - -export const router = createBrowserRouter([ - {path: '/login', element: }, - // 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다. - {path: '/signup', element: }, - - // 비로그인 = 랜딩, 로그인 = 내 사이트. Home 이 그걸 가른다. - {path: '/', element: }, - - // 로그인 전 화면. ★ 랜딩과 같은 껍데기(MarketingShell)를 쓴다 — 사이드바 없는 문서형이다. - {path: '/pricing', element: }, - {path: '/showcase', element: }, - - // 로그인한 사장님의 홈. 만든 사이트를 열고 고치는 자리다. - { - path: '/sites', - element: ( - - - - ), - }, - { - path: '/account', - element: ( - - - - ), - }, - - /** - * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면 - * 만들어 보기도 전에 막힌다. - * - * 대신 세션은 조용히 확보한다 — VITE_AUTO_LOGIN_ID·PW 가 주입돼 있으면 useAutoLogin() 이 - * 그 계정으로 붙고, 없으면 서버가 필요한 순간(2단계 검색)에만 알린다. - * - * 에디터(6단계)는 전체 화면이 필요해 AppShell 을 스스로 끄고 켠다 — BuilderPage 참조. - */ - {path: '/builder', element: }, - - /** - * ★ 내부 운영 화면(/places, /local-content, /seo)은 여기 없다 — 최상단 `admin/` 앱으로 나갔다. - * 라우트 가드는 화면을 가리지 **번들은 못 가린다**: 한 앱이면 내부 라우트 이름과 코드가 - * 사장님 브라우저에 그대로 내려간다. `UserRole.DEVELOPER` 주석의 - * "고객사에 존재를 노출하지 않는다"를 지키려면 번들 자체가 갈라져야 한다. - * (ARCHITECTURE.md 4절) - */ - - // 개발 빌드에서만 열리는 컴포넌트 쇼케이스. 운영 번들에서는 라우트 자체가 없다. - ...(import.meta.env.DEV ? [{path: '/dev/showcase', element: }] : []), - - {path: '*', element: }, -]); diff --git a/solution/frontend/src/components/layout/RequireAuthLayout.tsx b/solution/frontend/src/components/layout/RequireAuthLayout.tsx new file mode 100644 index 0000000..75eb24f --- /dev/null +++ b/solution/frontend/src/components/layout/RequireAuthLayout.tsx @@ -0,0 +1,14 @@ +import {Outlet} from 'react-router'; +import {RequireAuth} from './RequireAuth'; + +/** + * 인증이 필요한 라우트들의 부모. 예전 `router.tsx` 가 페이지마다 로 + * 감싸던 것을 레이아웃 라우트 하나로 모았다 — 가드가 한 자리에 있어야 빠뜨리지 않는다. + */ +export default function RequireAuthLayout() { + return ( + + + + ); +} diff --git a/solution/frontend/src/features/builder/CanvasView.tsx b/solution/frontend/src/features/builder/CanvasView.tsx index 4ed1789..a41eee3 100644 --- a/solution/frontend/src/features/builder/CanvasView.tsx +++ b/solution/frontend/src/features/builder/CanvasView.tsx @@ -6,11 +6,14 @@ import {deriveSurfaces} from '@/lib/color'; import {cn} from '@/lib/utils'; import {useBuilderStore, useCurrentTemplate} from '@/stores/builder'; import {resolveVariant} from './canvas/registry'; +import {PUBLISH_HOST} from '@/lib/site'; // ★ 호스트를 상수로 박지 않는다. PublishModal 과 **다른 주소**를 보여주면 사장님은 // 미리보기에서 본 주소와 발행 후 안내받는 주소가 달라 어느 쪽이 진짜인지 알 수 없다. // 같은 규칙(VITE_PUBLISH_HOST → 없으면 현재 호스트)을 쓴다. -const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host; +// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌 +// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다. +// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다) const VIEWPORT_FRAME: Record = { pc: 'w-full max-w-5xl shadow-md', diff --git a/solution/frontend/src/features/marketing/ShowcaseGrid.tsx b/solution/frontend/src/features/marketing/ShowcaseGrid.tsx index 9405014..3ec3925 100644 --- a/solution/frontend/src/features/marketing/ShowcaseGrid.tsx +++ b/solution/frontend/src/features/marketing/ShowcaseGrid.tsx @@ -1,6 +1,7 @@ import {useEffect, useState, type CSSProperties} from 'react'; import {Building2, Coffee, ImageOff, Stethoscope, UtensilsCrossed} from 'lucide-react'; import {PlaceCategory} from '@o2o/shared'; +import {ORIGIN} from '@/lib/site'; import {fetchShowcase, type ShowcaseItem} from './showcaseApi'; const CATEGORY_ICON: Record = { @@ -17,11 +18,14 @@ const CATEGORY_LABEL: Record = { [PlaceCategory.CLINIC]: '피부과 · 성형외과', }; -/** 발행 사이트 주소는 루트 상대경로로 온다(`/s/`). 발행 호스트는 번들에 구워진 값이다. */ -const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host; - +/** + * 발행 사이트 주소는 루트 상대경로로 온다(`/s/`). + * + * ★ window 로 떨어지지 않는다. 이 컴포넌트는 랜딩(`/`)과 `/showcase` 에 있고 그 둘은 + * 프리렌더 대상이다 — 빌드 때 브라우저 없이 한 번 그려지므로 window 를 만지면 죽는다. + */ function siteHref(url: string): string { - return `${window.location.protocol}//${PUBLISH_HOST}${url}`; + return `${ORIGIN}${url}`; } /** diff --git a/solution/frontend/src/features/publish/PublishModal.tsx b/solution/frontend/src/features/publish/PublishModal.tsx index 0e8aaac..3b66929 100644 --- a/solution/frontend/src/features/publish/PublishModal.tsx +++ b/solution/frontend/src/features/publish/PublishModal.tsx @@ -19,6 +19,7 @@ import {cn} from '@/lib/utils'; import {useBuilderStore} from '@/stores/builder'; import {runPublishGate, type GateFinding} from './publishGate'; import {checkSiteSlug, reserveSiteSlug} from './siteSlug'; +import {PUBLISH_HOST} from '@/lib/site'; import {localValidate, SlugField, type SlugStatus} from './SlugField'; import { GATE_REASON_LABEL, @@ -29,7 +30,9 @@ import { // 개발에서는 admin 과 같은 :3000을 공개 주소로 쓴다. `/s` 요청은 Vite가 정적 사이트 // 서버(:3001)로 프록시한다. 운영에서는 VITE_PUBLISH_HOST로 공개 호스트를 명시할 수 있다. -const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host; +// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌 +// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다. +// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다) export function PublishModal() { const isOpen = useBuilderStore((s) => s.isPublishModalOpen); diff --git a/solution/frontend/src/lib/site.ts b/solution/frontend/src/lib/site.ts new file mode 100644 index 0000000..eedc0d1 --- /dev/null +++ b/solution/frontend/src/lib/site.ts @@ -0,0 +1,15 @@ +/** + * 이 앱이 서비스되는 공개 주소. + * + * ★ 호스트를 문자열로 두 곳에 적지 않는다 — compose 가 루트 `SITE_PUBLIC_HOST` 를 + * `VITE_PUBLISH_HOST` 로 흘려보낸다(AGENTS.md). 여기가 프론트에서의 유일한 자리다. + * ★ `window.location` 으로 떨어지지 않는다. 프리렌더는 브라우저 없이 도는데, + * 그때 window 를 만지면 빌드가 죽는다. + */ +const HOST = import.meta.env.VITE_PUBLISH_HOST || 'web4ai.o2osolution.ai'; + +/** `https://web4ai.o2osolution.ai` — 끝 슬래시 없음. */ +export const ORIGIN = `https://${HOST}`; + +/** 발행 사이트 주소를 만들 때 쓰는 호스트. */ +export const PUBLISH_HOST = HOST; diff --git a/solution/frontend/src/pages/AccountPage.tsx b/solution/frontend/src/pages/AccountPage.tsx index 79fa550..6c2b9fb 100644 --- a/solution/frontend/src/pages/AccountPage.tsx +++ b/solution/frontend/src/pages/AccountPage.tsx @@ -142,3 +142,6 @@ function Field({label, children}: {label: string; children: React.ReactNode}) { ); } + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default AccountPage; diff --git a/solution/frontend/src/pages/BuilderPage.tsx b/solution/frontend/src/pages/BuilderPage.tsx index bedb166..9f596bc 100644 --- a/solution/frontend/src/pages/BuilderPage.tsx +++ b/solution/frontend/src/pages/BuilderPage.tsx @@ -18,9 +18,12 @@ import {useAutoLogin} from '@/hooks/useAutoLogin'; import {usePlaceSync} from '@/hooks/usePlaceSync'; import {userLabel, useAuthStore} from '@/stores/auth'; import {useBuilderStore} from '@/stores/builder'; +import {ORIGIN} from '@/lib/site'; /** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */ -const SITE_PREVIEW_URL = import.meta.env.VITE_SITE_PREVIEW_URL ?? window.location.origin; +// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌 +// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다. +const SITE_PREVIEW_URL = import.meta.env.VITE_SITE_PREVIEW_URL || ORIGIN; /** 랜딩이 `?industry=` 로 넘길 수 있는 값. 주소창 값이라 아무 문자열이나 들어올 수 있다. */ const INDUSTRY_VALUES: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic']; @@ -307,3 +310,6 @@ function BuilderNotice({
); } + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default BuilderPage; diff --git a/solution/frontend/src/pages/DevShowcasePage.tsx b/solution/frontend/src/pages/DevShowcasePage.tsx index 1a0dfc0..1be8b83 100644 --- a/solution/frontend/src/pages/DevShowcasePage.tsx +++ b/solution/frontend/src/pages/DevShowcasePage.tsx @@ -701,3 +701,6 @@ function GroupList({ ); } + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default DevShowcasePage; diff --git a/solution/frontend/src/pages/LandingPage.tsx b/solution/frontend/src/pages/LandingPage.tsx index 0698917..8352693 100644 --- a/solution/frontend/src/pages/LandingPage.tsx +++ b/solution/frontend/src/pages/LandingPage.tsx @@ -4,6 +4,44 @@ import {ArrowRight, ArrowUp, Check} from 'lucide-react'; import {MarketingShell, Section, SectionHead} from '@/components/layout/MarketingShell'; import {ShowcaseGrid, ShowcasePeeks} from '@/features/marketing/ShowcaseGrid'; import {Button} from '@/components/ui/button'; +import type {MetaFunction} from 'react-router'; +import {ORIGIN} from '@/lib/site'; +/** + * ★ 제목은 브랜드가 아니라 **사람이 검색창에 치는 말**로 시작한다. "Web4Ai" 는 검색량이 + * 없는 신규 이름이고, 예전 제목("Web4Ai · AI 웹 빌더")에는 검색어가 한 단어도 없었다. + * seo 규칙: 핵심 키워드 앞, 브랜드 뒤, 50~60자. + */ +export const meta: MetaFunction = () => [ + {title: '가게 홈페이지 만들기 · AI 검색에 인용되는 방식으로 | Web4Ai'}, + { + name: 'description', + content: + '네이버 플레이스만 있으면 우리 가게 홈페이지가 만들어집니다. 주소·영업시간·메뉴가 구조화 데이터로 함께 나가 검색엔진과 AI 답변이 함께 읽습니다. 사업자가 확인한 정보만 발행합니다.', + }, + {name: 'robots', content: 'index, follow, max-snippet:-1, max-image-preview:large'}, + {tagName: 'link', rel: 'canonical', href: `${ORIGIN}/`}, + {property: 'og:title', content: '가게 홈페이지 만들기 · AI 검색에 인용되는 방식으로'}, + { + property: 'og:description', + content: '네이버 플레이스만 있으면 검색·AI 답변이 함께 읽는 우리 가게 홈페이지가 만들어집니다.', + }, + {property: 'og:url', content: `${ORIGIN}/`}, + // 이 앱이 무엇인지 기계에게 말하는 유일한 자리다. 발행본에는 LodgingBusiness 가 나가는데 + // 정작 랜딩에는 구조화 데이터가 하나도 없었다. + { + 'script:ld+json': { + '@context': 'https://schema.org', + '@type': 'Organization', + '@id': `${ORIGIN}/#org`, + name: 'Web4Ai', + url: `${ORIGIN}/`, + logo: `${ORIGIN}/brand/favicon-w4a.svg`, + description: + '네이버 플레이스 정보를 사업자가 확인해 가게 공식 홈페이지로 발행하는 서비스. 검색엔진과 AI 답변엔진이 함께 읽는 형식으로 내보냅니다.', + }, + }, +]; + /** * 히어로 제목의 앞말. '홈페이지' 는 고정이고 이 여섯이 돌아간다. @@ -279,3 +317,6 @@ export function LandingPage() { ); } + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default LandingPage; diff --git a/solution/frontend/src/pages/LoginPage.tsx b/solution/frontend/src/pages/LoginPage.tsx index 08478b3..2733f7a 100644 --- a/solution/frontend/src/pages/LoginPage.tsx +++ b/solution/frontend/src/pages/LoginPage.tsx @@ -19,7 +19,8 @@ import {useAuthStore} from '@/stores/auth'; */ export function LoginPage({ selfServe = true, - homePath = '/', + // 로그인 직후엔 내 사이트로. 예전엔 router.tsx 가 넘기던 값이다. + homePath = '/sites', }: { selfServe?: boolean; /** @@ -165,3 +166,6 @@ export function LoginPage({ ); } + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default LoginPage; diff --git a/solution/frontend/src/pages/NotFoundPage.tsx b/solution/frontend/src/pages/NotFoundPage.tsx index 9170b7b..e103633 100644 --- a/solution/frontend/src/pages/NotFoundPage.tsx +++ b/solution/frontend/src/pages/NotFoundPage.tsx @@ -14,3 +14,6 @@ export function NotFoundPage() { ); } + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default NotFoundPage; diff --git a/solution/frontend/src/pages/PricingPage.tsx b/solution/frontend/src/pages/PricingPage.tsx index 0388cb7..98f7d8b 100644 --- a/solution/frontend/src/pages/PricingPage.tsx +++ b/solution/frontend/src/pages/PricingPage.tsx @@ -2,6 +2,20 @@ import {Link} from 'react-router'; import {Check} from 'lucide-react'; import {MarketingShell, Section} from '@/components/layout/MarketingShell'; import {Button} from '@/components/ui/button'; +import type {MetaFunction} from 'react-router'; +import {ORIGIN} from '@/lib/site'; +export const meta: MetaFunction = () => [ + {title: '요금 · 가게 홈페이지 제작 비용 | Web4Ai'}, + { + name: 'description', + content: 'Web4Ai 로 가게 홈페이지를 만들고 발행하는 데 드는 비용입니다. 발행 후에도 AI 인용 상태를 계속 확인합니다.', + }, + {name: 'robots', content: 'index, follow'}, + {tagName: 'link', rel: 'canonical', href: `${ORIGIN}/pricing`}, + {property: 'og:title', content: '요금 · 가게 홈페이지 제작 비용'}, + {property: 'og:url', content: `${ORIGIN}/pricing`}, +]; + /** * 요금 — 플랜 하나다. @@ -107,3 +121,6 @@ export function PricingPage() { ); } + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default PricingPage; diff --git a/solution/frontend/src/pages/ShowcasePage.tsx b/solution/frontend/src/pages/ShowcasePage.tsx index 7dd3fed..fcafe95 100644 --- a/solution/frontend/src/pages/ShowcasePage.tsx +++ b/solution/frontend/src/pages/ShowcasePage.tsx @@ -2,6 +2,20 @@ import {Link} from 'react-router'; import {MarketingShell, Section} from '@/components/layout/MarketingShell'; import {ShowcaseGrid} from '@/features/marketing/ShowcaseGrid'; import {Button} from '@/components/ui/button'; +import type {MetaFunction} from 'react-router'; +import {ORIGIN} from '@/lib/site'; +export const meta: MetaFunction = () => [ + {title: '먼저 시작한 가게들 · 발행 사례 | Web4Ai'}, + { + name: 'description', + content: 'Web4Ai 로 실제 발행된 가게 홈페이지 사례입니다. 모든 정보는 사업자가 확인한 것만 담겨 있습니다.', + }, + {name: 'robots', content: 'index, follow'}, + {tagName: 'link', rel: 'canonical', href: `${ORIGIN}/showcase`}, + {property: 'og:title', content: '먼저 시작한 가게들 · 발행 사례'}, + {property: 'og:url', content: `${ORIGIN}/showcase`}, +]; + /** * 이렇게 나옵니다 — 실제로 발행된 홈페이지 목록. @@ -35,3 +49,6 @@ export function ShowcasePage() { ); } + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default ShowcasePage; diff --git a/solution/frontend/src/pages/SignupPage.tsx b/solution/frontend/src/pages/SignupPage.tsx index 0ec2026..6048fa1 100644 --- a/solution/frontend/src/pages/SignupPage.tsx +++ b/solution/frontend/src/pages/SignupPage.tsx @@ -219,3 +219,6 @@ export function SignupPage() { ); } + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default SignupPage; diff --git a/solution/frontend/src/pages/SitesPage.tsx b/solution/frontend/src/pages/SitesPage.tsx index f6f10e7..7c70a0f 100644 --- a/solution/frontend/src/pages/SitesPage.tsx +++ b/solution/frontend/src/pages/SitesPage.tsx @@ -18,9 +18,12 @@ import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell' import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {notify, notifyApiError} from '@/lib/notify'; +import {PUBLISH_HOST} from '@/lib/site'; // 발행본 주소는 PublishModal·CanvasView 와 같은 규칙이다 — 세 곳이 다른 주소를 말하면 안 된다. -const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host; +// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌 +// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다. +// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다) const CATEGORY_ICON: Record = { [PlaceCategory.LODGING]: Building2, @@ -211,3 +214,6 @@ export function SitesPage() { ); } + +// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다). +export default SitesPage; diff --git a/solution/frontend/src/root.tsx b/solution/frontend/src/root.tsx new file mode 100644 index 0000000..cece0f4 --- /dev/null +++ b/solution/frontend/src/root.tsx @@ -0,0 +1,82 @@ +import {isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration} from 'react-router'; +import type {LinksFunction, MetaFunction} from 'react-router'; +import {Providers} from '@/app/provider'; +import './index.css'; + +/** + * 문서 껍데기. 예전 `index.html` 이 하던 일을 여기서 한다 — + * 프레임워크 모드에는 index.html 이 없고, 이 컴포넌트가 부터 그린다. + * + * ★ 발행 호스트를 여기 문자열로 적지 않는다. `VITE_PUBLISH_HOST` 는 compose 가 루트의 + * `SITE_PUBLIC_HOST` 를 흘려보내는 값이다 — 두 곳에 적으면 canonical 과 화면 주소가 + * 조용히 갈라진다(AGENTS.md). + */ +const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST || 'web4ai.o2osolution.ai'; +export const ORIGIN = `https://${PUBLISH_HOST}`; + +export const links: LinksFunction = () => [ + {rel: 'icon', type: 'image/svg+xml', href: '/brand/favicon-w4a.svg?v=14'}, + // self-host Pretendard 가 어떤 환경에서든 못 뜰 때 한글이 굴림/맑은고딕으로 떨어지지 않게 하는 안전망 + {rel: 'preconnect', href: 'https://fonts.googleapis.com'}, + {rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous'}, + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300..900&family=Noto+Serif+KR:wght@400;600;700&display=swap', + }, + // 7080 아이템(가요 다방·일력·승차권) 전용. 이 셋이 없으면 간판/명조가 고딕으로 떨어져 감성이 사라진다 + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Gugi&family=Gowun+Batang:wght@400;700&family=Nanum+Pen+Script&display=swap', + }, +]; + +/** 라우트가 자기 것을 안 내놓을 때의 기본값. 각 페이지는 `meta` 를 export 해 덮어쓴다. */ +export const meta: MetaFunction = () => [ + {title: 'Web4Ai'}, + {property: 'og:site_name', content: 'Web4Ai'}, + {property: 'og:locale', content: 'ko_KR'}, + {property: 'og:type', content: 'website'}, + {name: 'twitter:card', content: 'summary_large_image'}, +]; + +export function Layout({children}: {children: React.ReactNode}) { + return ( + + + + + + + + + + {children} + + + + + ); +} + +export default function Root() { + return ( + + + + ); +} + +export function ErrorBoundary({error}: {error: unknown}) { + const is404 = isRouteErrorResponse(error) && error.status === 404; + return ( + + ); +} diff --git a/solution/frontend/src/routes.ts b/solution/frontend/src/routes.ts new file mode 100644 index 0000000..dac2bb8 --- /dev/null +++ b/solution/frontend/src/routes.ts @@ -0,0 +1,42 @@ +import {index, layout, route, type RouteConfig} from '@react-router/dev/routes'; + +/** + * 라우트 표. 예전 `app/router.tsx` 의 배열이 여기로 왔다. + * + * ★ 순서·경로는 그대로다. 바뀐 건 선언 방식뿐이고, 페이지 컴포넌트는 손대지 않았다. + * ★ 프리렌더 대상은 여기가 아니라 `react-router.config.ts` 가 정한다. + * 목록에 없는 경로는 지금까지처럼 SPA 폴백으로 나간다. + */ +export default [ + // 비로그인 = 랜딩. ★ 예전엔 로그인 여부로 갈랐는데 지금은 둘 다 랜딩이다 + // (2026-09-04, 사장님 지적: 로그인하면 랜딩·요금에 갈 길이 없었다). + index('pages/LandingPage.tsx'), + + route('login', 'pages/LoginPage.tsx'), + // 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다. + route('signup', 'pages/SignupPage.tsx'), + + // 로그인 전 화면. ★ 랜딩과 같은 껍데기(MarketingShell)를 쓴다 — 사이드바 없는 문서형이다. + route('pricing', 'pages/PricingPage.tsx'), + route('showcase', 'pages/ShowcasePage.tsx'), + + // 로그인한 사장님의 홈. 예전엔 페이지마다 로 감쌌는데, + // 가드는 한 자리에 있어야 빠뜨리지 않아서 레이아웃 라우트로 모았다. + layout('components/layout/RequireAuthLayout.tsx', [ + route('sites', 'pages/SitesPage.tsx'), + route('account', 'pages/AccountPage.tsx'), + ]), + + /** + * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면 + * 만들어 보기도 전에 막힌다. 세션은 useAutoLogin() 이 조용히 확보한다. + */ + route('builder', 'pages/BuilderPage.tsx'), + + // 개발 빌드에서만 열린다. 운영 번들에는 라우트 자체가 없다. + ...(process.env.NODE_ENV !== 'production' + ? [route('dev/showcase', 'pages/DevShowcasePage.tsx')] + : []), + + route('*', 'pages/NotFoundPage.tsx'), +] satisfies RouteConfig; diff --git a/solution/frontend/vite.config.ts b/solution/frontend/vite.config.ts index 66f41dd..738d6bb 100644 --- a/solution/frontend/vite.config.ts +++ b/solution/frontend/vite.config.ts @@ -1,10 +1,12 @@ +import {reactRouter} from '@react-router/dev/vite'; import tailwindcss from '@tailwindcss/vite'; -import react from '@vitejs/plugin-react'; import path from 'path'; import {defineConfig} from 'vite'; export default defineConfig({ - plugins: [react(), tailwindcss()], + // ★ @vitejs/plugin-react 를 따로 넣지 않는다 — reactRouter() 가 안에서 켠다. + // 둘 다 넣으면 리프레시 런타임이 두 번 주입돼 HMR 이 깨진다. + plugins: [reactRouter(), tailwindcss()], resolve: { alias: { '@': path.resolve(__dirname, 'src'), @@ -18,6 +20,9 @@ export default defineConfig({ '@o2o/shared': path.resolve(__dirname, '../shared/src'), }, }, + // ★ 산출물이 `dist/` 가 아니라 `build/client/` 로 나간다(프레임워크 모드 기본값). + // nginx/Dockerfile 의 COPY 경로가 이 값과 맞아야 한다 — 어긋나면 빈 이미지가 구워지고 + // 컨테이너는 정상으로 뜬다. build: { // ★ 발행본과 **같은 오리진**을 쓰므로 `/assets/` 를 서로 뺏는다. 빌더 번들만 다른 // 디렉토리로 뺀다 — 안 그러면 nginx 의 `/assets/` 규칙이 발행본 것만 주고 diff --git a/solution/site/src/seo/robots.ts b/solution/site/src/seo/robots.ts index fd6fd81..a75d653 100644 --- a/solution/site/src/seo/robots.ts +++ b/solution/site/src/seo/robots.ts @@ -33,11 +33,22 @@ const SEARCH_CRAWLERS = [ 'Daumoa', // 다음 ]; +/** + * 앱 전용 경로 — 크롤러가 볼 것이 없는 자리. + * + * ★ 로그인해야 내용이 생기거나(`/sites` `/account`), 도구 화면이라(`/builder`) 색인할 + * 내용이 아예 없다. 프리렌더를 켠 뒤 이 경로들은 빈 SPA 폴백을 받는데, 그걸 긁히면 + * 호스트 전체에 "내용 없는 페이지" 신호가 쌓인다. + * ★ `/showcase` 는 막지 않는다 — 프리렌더 대상이고 발행 사례를 담는다. + */ +const APP_ONLY_PATHS = ['/builder', '/login', '/signup', '/sites', '/account']; + /** 크롤러 허용 블록 — 루트용과 사이트용이 같은 목록을 쓴다. */ function allowBlocks(): string[] { - const blocks: string[] = ['User-agent: *', 'Allow: /', '']; + const deny = APP_ONLY_PATHS.map((path) => `Disallow: ${path}`); + const blocks: string[] = ['User-agent: *', 'Allow: /', ...deny, '']; for (const bot of [...SEARCH_CRAWLERS, ...AI_CRAWLERS]) { - blocks.push(`User-agent: ${bot}`, 'Allow: /', ''); + blocks.push(`User-agent: ${bot}`, 'Allow: /', ...deny, ''); } return blocks; } From 09b0538c9c89ca0d5867426b138eb0a4ea5a9897 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 7 Sep 2026 11:23:44 +0900 Subject: [PATCH 05/18] =?UTF-8?q?[fix]=20solution/site:=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=EB=A7=B5=20lastmod=20=EB=A5=BC=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20mtime=20=EC=97=90=EC=84=9C=20=EB=97=80=EB=8B=A4=20?= =?UTF-8?q?=E2=80=94=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=9D=98=20dateModified?= =?UTF-8?q?=20=EB=A5=BC=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=EC=93=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lastmod 를 구운 index.html 의 mtime 에서 읽었다. 렌더러를 배포하면 번들 해시가 바뀌어 내용이 같은 사이트까지 전부 다시 구워지고, mtime 은 그때마다 오늘이 된다 — 사이트맵이 "전 사이트가 오늘 갱신됨" 을 통보한다. 구글은 lastmod 를 페이지의 실제 수정과 대조해 맞을 때만 쓰고 어긋나면 필드를 아예 무시한다 (Search Central: "the date and time of the last significant update" · "consistently and verifiably accurate"). 지금 뭘 깨뜨리는 게 아니라, 사장님이 진짜로 내용을 고쳐 재발행한 날의 신호를 미리 죽여 두는 종류다. 사이트가 100개를 넘기면 되돌리는 데 시간이 걸린다. - seo/directory.ts: readBakedTitle · readBakedLastmod 추가. lastmod 는 head 가 선언한 dateModified(= payload.site.updatedAt) 그 값이다 — 구글이 대조하는 값과 같아 어긋날 수 없다 - scripts/prerender.ts: 사이트맵 항목에서 mtime 제거, 파일 한 번 읽어 제목·lastmod 동시 추출. mtime 은 dateModified 메타가 없던 산출물에만 남는 폴백이다 - seo/directory.test.ts: head.ts 태그와 파서의 커플링 고정 — 모양이 바뀌면 파서가 조용히 undefined 를 내고 mtime 으로 되돌아간다(빌드도 화면도 멀쩡한 회귀) - docs/DEVLOG.md tsc·eslint 통과, vitest 22 passed (신규 5건) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xTWrJ6Mrr6HhEN6hZEER4 --- docs/DEVLOG.md | 27 +++++++++++++++ solution/site/scripts/prerender.ts | 35 +++++++++---------- solution/site/src/seo/directory.test.ts | 46 +++++++++++++++++++++++++ solution/site/src/seo/directory.ts | 32 +++++++++++++++++ 4 files changed, 122 insertions(+), 18 deletions(-) create mode 100644 solution/site/src/seo/directory.test.ts diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 9541b9e..f56c53f 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,33 @@ --- +## 2026-09-07 — 사이트맵 lastmod 를 파일 mtime 에서 뗐다 + +**왜** +`lastmod` 를 구운 `index.html` 의 **파일 mtime** 에서 읽고 있었다. 그런데 렌더러를 배포하면 +번들 해시가 바뀌어 **내용이 한 글자도 안 바뀐 사이트까지 전부 다시 구워진다** — mtime 은 +그때마다 오늘이 되고, 사이트맵은 "전 사이트가 오늘 갱신됨" 을 통보한다. + +구글은 lastmod 를 페이지의 실제 수정과 대조해 맞을 때만 쓰고, 어긋나면 **그 필드를 아예 +무시한다**(Search Central: "the date and time of the last significant update" · +"consistently and verifiably accurate"). 즉 이 오염은 지금 당장 뭘 깨뜨리는 게 아니라, +**사장님이 진짜로 내용을 고쳐 재발행한 날의 신호를 미리 죽여 두는** 종류다. 배포할 때마다 +신뢰를 태우고 있었고, 사이트가 100개를 넘기면 되돌리는 데 시간이 걸린다. + +**바꾼 것** +- `seo/directory.ts`: `readBakedTitle` · `readBakedLastmod` — 구운 HTML 에서 목록·사이트맵 + 값을 꺼낸다. lastmod 는 페이지가 head 에 선언한 `dateModified`(= `payload.site.updatedAt`) + **그 값 그대로**다. 구글이 대조하는 값과 글자 그대로 같아 어긋날 수가 없다 +- `scripts/prerender.ts`: `readTitle` 을 위로 옮기고 사이트맵 항목에서 mtime 제거. 파일을 + 한 번만 읽어 제목과 lastmod 를 같이 꺼낸다. mtime 은 `dateModified` 메타가 없던 시절의 + 산출물에만 남는 폴백이다 — 그 사이트를 한 번 다시 구우면 제 값이 들어온다 +- `seo/directory.test.ts`: head.ts 의 메타와 파서의 **커플링을 고정**한다. 태그 모양이 바뀌면 + 파서가 조용히 undefined 를 내고 mtime 으로 되돌아간다 — 빌드도 화면도 멀쩡한 회귀라서 붙였다 + +**검증** — `tsc·eslint` 통과, `vitest` 22 passed (신규 5건). + +--- + ## 2026-09-03 — 레포·발행 호스트 교체 — `o2o-site-AEO` / `web4ai.o2osolution.ai` **왜** diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index e8ab11d..b6395e9 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -22,6 +22,8 @@ import {render} from '@/entry-server'; import { collectJsonLd, homeMeta, + readBakedLastmod, + readBakedTitle, renderHead, renderLlmsTxt, renderRootLlmsTxt, @@ -518,18 +520,21 @@ function writeRootMachineFiles(outRoot: string, origin: string) { .map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')})) // index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. 사이트맵에 넣지 않는다. .filter((entry) => existsSync(entry.file)) - .map((entry) => ({ - // ★ 끝 슬래시를 붙이지 않는다. 페이지의 canonical 은 `/s/` 다(shared/lib/slug.ts - // publishUrl). 사이트맵이 `/s//` 로 어긋나 있던 동안 서치콘솔은 제출한 URL 을 - // 전부 "대체 페이지(적절한 표준 태그가 있음)" 로 분류했다 — 색인은 되는데 제출분은 - // 0건으로 보이는, 눈으로 원인을 못 찾는 종류다. - loc: joinUrl(origin, SITE_DIR, entry.slug), - // 제목은 구운 HTML 에서 읽는다. payload 에서 가져오면 이번 실행분만 이름이 있고 - // 나머지는 슬러그로 떨어진다 — 발행은 바뀐 사이트 하나만 굽기 때문이다. - title: readTitle(entry.file) || entry.slug, - // 페이지는 그 사이트를 구울 때마다 다시 쓰인다 — 파일 mtime 이 곧 마지막 발행 시각이다. - lastmod: statSync(entry.file).mtime.toISOString(), - })) + .map((entry) => { + // 제목과 lastmod 가 같은 HTML 에서 나온다 — 파일은 한 번만 읽는다. + const html = readFileSync(entry.file, 'utf-8'); + return { + // ★ 끝 슬래시를 붙이지 않는다. 페이지의 canonical 은 `/s/` 다(shared/lib/slug.ts + // publishUrl). 사이트맵이 `/s//` 로 어긋나 있던 동안 서치콘솔은 제출한 URL 을 + // 전부 "대체 페이지(적절한 표준 태그가 있음)" 로 분류했다 — 색인은 되는데 제출분은 + // 0건으로 보이는, 눈으로 원인을 못 찾는 종류다. + loc: joinUrl(origin, SITE_DIR, entry.slug), + title: readBakedTitle(html) || entry.slug, + // ★ mtime 으로 떨어지는 건 dateModified 메타가 없던 시절의 산출물뿐이다. + // 그 사이트를 한 번 다시 구우면 제 값이 들어온다(readBakedLastmod 주석 참조). + lastmod: readBakedLastmod(html) ?? statSync(entry.file).mtime.toISOString(), + }; + }) .sort((a, b) => a.loc.localeCompare(b.loc)); // ★ `/s/` 목록 페이지. 크롤러가 발행본에 닿는 두 번째 경로다 — @@ -549,12 +554,6 @@ function writeRootMachineFiles(outRoot: string, origin: string) { writeIndexNowKey(outRoot); } -/** 구운 index.html 에서 만 꺼낸다. 파서를 붙일 값어치가 없는 한 줄짜리 일이다. */ -function readTitle(file: string): string { - const match = /<title>([^<]*)<\/title>/.exec(readFileSync(file, 'utf-8')); - return match ? match[1].trim() : ''; -} - /** * IndexNow 키 파일 — `https://<host>/<key>.txt` 에 키 문자열만 들어 있다. * diff --git a/solution/site/src/seo/directory.test.ts b/solution/site/src/seo/directory.test.ts new file mode 100644 index 0000000..958cedf --- /dev/null +++ b/solution/site/src/seo/directory.test.ts @@ -0,0 +1,46 @@ +/** + * 구운 HTML → 사이트맵·목록 값. + * + * ★ 이 테스트가 지키는 것은 **커플링 하나**다. 사이트맵의 lastmod 는 페이지가 head 에 + * 선언한 dateModified 와 같은 값이어야 한다 — 구글이 lastmod 를 신뢰하는 조건이 + * "페이지의 실제 수정과 대조해 맞을 것" 이기 때문이다. head.ts 가 태그 모양을 바꾸면 + * readBakedLastmod 는 조용히 undefined 를 내고 사이트맵은 mtime 으로 되돌아간다 — + * 빌드는 통과하고 화면도 멀쩡한, 눈으로 못 찾는 종류의 회귀다. 그래서 붙인다. + */ +import {describe, expect, it} from 'vitest'; + +import {MOONLIGHT_STAY_PAYLOAD} from '../fixtures/moonlight-stay'; +import {readBakedLastmod, readBakedTitle} from './directory'; +import {renderHead} from './head'; +import {homeMeta} from './meta'; + +/** 실제 프리렌더와 같은 경로로 head 를 굽는다(prerender.ts prerenderSite). */ +function bakedHead(): string { + const payload = MOONLIGHT_STAY_PAYLOAD; + return renderHead({payload, meta: homeMeta(payload)}); +} + +describe('readBakedLastmod', () => { + it('head 가 선언한 dateModified 를 그대로 돌려준다', () => { + expect(readBakedLastmod(bakedHead())).toBe(MOONLIGHT_STAY_PAYLOAD.site.updatedAt); + }); + + it('메타가 없으면 undefined — 호출부가 mtime 으로 떨어진다', () => { + expect(readBakedLastmod('<html><head><title>x')).toBeUndefined(); + }); + + it('날짜로 못 읽는 값은 버린다 — 틀린 lastmod 는 없는 것보다 나쁘다', () => { + expect(readBakedLastmod('')).toBeUndefined(); + expect(readBakedLastmod('')).toBeUndefined(); + }); +}); + +describe('readBakedTitle', () => { + it('구운 HTML 의 title 을 꺼낸다', () => { + expect(readBakedTitle(bakedHead())).toBe(homeMeta(MOONLIGHT_STAY_PAYLOAD).title); + }); + + it('title 이 없으면 빈 문자열 — 호출부가 슬러그로 떨어진다', () => { + expect(readBakedTitle('')).toBe(''); + }); +}); diff --git a/solution/site/src/seo/directory.ts b/solution/site/src/seo/directory.ts index 6c87b9d..1dde43c 100644 --- a/solution/site/src/seo/directory.ts +++ b/solution/site/src/seo/directory.ts @@ -9,6 +9,38 @@ export interface DirectoryEntry { lastmod?: string; } +/** + * 구운 `index.html` 에서 `` 만 꺼낸다. 파서를 붙일 값어치가 없는 한 줄짜리 일이다. + * + * ★ 왜 payload 가 아니라 구운 HTML 을 읽나 — 발행은 **바뀐 사이트 하나만** 굽는다. + * 이번 실행분으로만 목록을 만들면 나머지 사이트가 목록에서 사라진다. + */ +export function readBakedTitle(html: string): string { + const match = /<title>([^<]*)<\/title>/.exec(html); + return match ? match[1].trim() : ''; +} + +/** + * 사이트맵 `lastmod` — **페이지가 스스로 선언한 `dateModified` 를 그대로** 쓴다. + * + * ★ 파일 mtime 을 쓰면 안 된다(예전 구현). 렌더러를 배포하면 번들 해시가 바뀌어 내용이 + * 같은 사이트까지 전부 다시 구워진다 — mtime 은 그때마다 오늘이 되고, 사이트맵은 + * **"전 사이트가 오늘 갱신됨"** 을 통보한다. 구글은 lastmod 를 페이지의 실제 수정과 + * 대조해 맞을 때만 쓰고 어긋나면 그 필드를 **아예 무시한다**(Search Central: "the date and + * time of the last significant update" · "consistently and verifiably accurate"). + * 즉 이 오염은 사장님이 **진짜로** 내용을 고쳐 재발행한 날의 신호까지 같이 죽인다. + * + * ★ head.ts 의 `dateModified` 메타와 **같은 값**을 읽는다 — 구글이 대조하는 그 값이라 + * 사이트맵과 페이지가 어긋날 수 없다. head.ts 가 이 태그를 바꾸면 여기도 같이 고친다 + * (directory.test.ts 가 그 커플링을 고정해 둔다). + */ +export function readBakedLastmod(html: string): string | undefined { + const match = /<meta name="dateModified" content="([^"]*)"/.exec(html); + const value = match?.[1].trim(); + // 값이 깨졌으면 넣지 않는다 — 틀린 lastmod 는 없는 것보다 나쁘다(위 ★ 참조). + return value && !Number.isNaN(Date.parse(value)) ? value : undefined; +} + /** * `/s/` 발행 사이트 목록 페이지. * From 8f6ea16f6595107636a5c4366e569cbcee9ce227 Mon Sep 17 00:00:00 2001 From: Mina Choi <mnchoi@o2o.kr> Date: Mon, 7 Sep 2026 11:27:52 +0900 Subject: [PATCH 06/18] =?UTF-8?q?[fix]=20solution/site:=20=EC=98=9B=20?= =?UTF-8?q?=ED=95=B4=EC=8B=9C=20=EC=9E=90=EC=82=B0=EC=9D=84=2030=EC=9D=BC?= =?UTF-8?q?=20=EB=82=A8=EA=B8=B4=EB=8B=A4=20=E2=80=94=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=EC=99=80=20=EC=A0=84=EC=B2=B4=20=EC=9E=AC=EA=B5=BD=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20=EB=97=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit writeSharedAssets 가 빌드마다 out/assets 를 통째로 지우고 다시 깔았다. HTML 은 자산 경로를 파일명 해시까지 박아 굽기 때문에, 렌더러를 배포하는 순간 아직 다시 굽지 않은 사이트는 전부 CSS·JS 404 였다. 그 구멍을 "기동 시 전체 재굽기"와 "배포하면 반드시 전체 재업로드"라는 규칙으로 막고 있었다 — 규칙으로 막는다는 건 구조가 못 막는다는 뜻이다. 진짜 위험은 방문자가 아니라 크롤러다. 구글은 HTML 을 가져간 뒤 렌더를 나중에 돌린다. 그 사이 자산이 사라지면 스타일도 스크립트도 없는 페이지를 렌더한 것으로 기록한다. 유예 창이 필요한 건 통념이고(Vercel 은 검색봇에 한해 스큐 보호 창을 60일로 늘린다), 우리 창은 0초였다. 한 벌이 400KB 안팎이라 한 달치를 남겨도 10MB 남짓이다. - scripts/prerender.ts: assets/ 통째 삭제 제거. 권한 때문에 지웠던 것인데 copyDirectoryFiles 가 파일마다 먼저 rmSync 하므로 그 문제는 그대로 해결된다 - ASSET_RETENTION_DAYS(30) · ASSET_MIN_BUILDS(2) — 기간이 지나도 직전 빌드는 남는다 - out/assets/.builds.json 대장 — mtime 으로 나이를 재지 않는다(복사·동기화가 시각을 갈아 버리면 옛 파일이 영원히 젊어지거나 산 파일이 지워진다). 발행마다 이 함수가 도므로 번들이 그대로면 줄을 늘리지 않고 맨 앞 줄의 시각만 갱신한다 - AGENTS.md 함정 항목 · docs/DEPLOY.md 2절 · docs/DEVLOG.md 남은 것: azure_static._upload_shared 가 매 발행마다 assets/ 전체를 올린다 — 보관 기간만큼 업로드량이 는다. Azure 는 지금 꺼져 있으므로 켤 때 기존 블롭 건너뛰기를 먼저 붙인다. 검증: 세 번 구워 확인 — 번들 해시가 바뀌어도 옛 파일 3개가 남고, 같은 번들로 다시 구우면 대장이 안 늘며(2줄 유지), 대장 마지막 줄을 60일 전으로 돌리자 그 빌드 파일 3개만 정리됐다. tsc·eslint 통과, vitest 22 passed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018xTWrJ6Mrr6HhEN6hZEER4 --- AGENTS.md | 9 ++- docs/DEPLOY.md | 20 ++++-- docs/DEVLOG.md | 35 ++++++++++ solution/site/scripts/prerender.ts | 103 ++++++++++++++++++++++++++++- 4 files changed, 156 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5652113..784c678 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,9 +28,12 @@ - **번들 파일명은 콘텐츠 해시다.** HTML 은 `/assets/index-DvNTmLhy.css` 를 **루트 절대경로**로 가리킨다. 경로는 프리렌더가 `dist/client/.vite/manifest.json` 에서 읽어 박는다 (`prerender.ts:160`). 렌더러 CSS 를 고치면 이름이 바뀐다. -- **로컬 `out/assets` 는 빌드마다 통째로 갈린다** (`prerender.ts:573` `rmSync`). 옛 해시 파일이 - 사라지므로 새 번들로 일부 사이트만 구우면 나머지는 CSS 가 404 다. - → 프리렌더 기동 시 전체 재굽기가 이 구멍을 메운다. +- **옛 해시 자산은 30일 남는다** (`prerender.ts` `ASSET_RETENTION_DAYS`). 예전에는 빌드마다 + `out/assets` 를 통째로 갈아서, 새 번들로 일부만 구우면 나머지 사이트가 CSS 404 였다. + 지금은 남긴다 — 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌리므로, 그 사이 자산이 사라지면 + 스타일 없는 페이지를 렌더한 것으로 기록된다. 보관 근거는 `out/assets/.builds.json` 대장이고 + 파일 mtime 이 아니다. + → 재굽기는 여전히 필요하지만 **급하지 않다**. 디자인이 반영 안 될 뿐, 깨지지는 않는다. - **★ 프론트(`solution/site`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.** `azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 — **렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.** diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 2490f3b..9a27c1c 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -66,13 +66,23 @@ site-out/ ``` 프론트 수정 → 새 번들(새 해시) - 로컬 out/assets : 통째로 교체 (옛 해시 삭제) ← 재굽기 안 한 사이트는 CSS 404 - Azure : 새 해시 추가, 옛 해시 유지 ← 안 깨지지만 옛 디자인 그대로 박제 + 로컬 out/assets : 새 해시 추가, 옛 해시 30일 보관 ← 안 깨진다. 옛 디자인으로 뜰 뿐 + Azure : 새 해시 추가, 옛 해시 유지 ← 같다 ``` -프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 그래서 로컬 out/ 은 재시작만 -하면 정합이 맞는다. 하지만 Azure 는 발행 잡이 도는 사이트 하나씩만 올린다 — 그 짝을 맞추는 게 -`solution/backend/scripts/republish_all.py` 다. +**2026-09-07 이전에는 로컬 `out/assets` 를 통째로 갈았다.** 그래서 재굽기 전까지 나머지 사이트가 +CSS 404 였다 — 하필 크롤러가 그 순간 렌더하면 스타일 없는 페이지를 본 것으로 기록된다. +지금은 `ASSET_RETENTION_DAYS`(30일) 동안 옛 해시를 남긴다. 보관 근거는 `out/assets/.builds.json` +대장이다(파일 mtime 이 아니다 — 복사·동기화가 시각을 갈아 버린다). + +**그래서 재굽기는 여전히 필요하지만 급하지는 않다.** 안 하면 그 사이트만 옛 디자인으로 뜬다. +프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 하지만 Azure 는 발행 잡이 도는 +사이트 하나씩만 올린다 — 그 짝을 맞추는 게 `solution/backend/scripts/republish_all.py` 다. + +⚠️ 남은 것: `azure_static._upload_shared` 는 매 발행마다 `assets/` **전체**를 다시 올린다. +옛 해시를 남기기 시작했으므로 보관 기간만큼 업로드량이 는다. Azure 를 켤 때는 이미 있는 +블롭(해시 파일이라 이름이 같으면 내용도 같다)을 건너뛰도록 먼저 고친다. + **규칙: `solution/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.** diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index f56c53f..0d33e6c 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,41 @@ --- +## 2026-09-07 — 옛 해시 자산을 30일 남긴다 — 배포와 재굽기를 뗀다 + +**왜** +`writeSharedAssets` 가 빌드마다 `out/assets` 를 통째로 지우고 다시 깔았다. HTML 은 자산 경로를 +파일명 해시까지 박아 굽기 때문에, 렌더러를 배포하는 순간 **아직 다시 굽지 않은 사이트는 전부 +CSS·JS 404** 였다. 구멍을 "기동 시 전체 재굽기" 와 "배포하면 반드시 전체 재업로드" 라는 **규칙** +으로 막고 있었다 — 규칙으로 막는다는 건 구조가 못 막는다는 뜻이다. + +진짜 위험은 방문자가 아니라 크롤러다. 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌린다. +그 사이에 자산이 사라지면 스타일도 스크립트도 없는 페이지를 렌더한 것으로 기록한다 — +하필 지금이 신규 도메인이 평가받는 시기다. 유예 창이 필요하다는 건 업계 통념이고 +(Vercel 은 검색봇에 한해 스큐 보호 창을 60일로 늘린다), 우리 창은 0초였다. + +**바꾼 것** (`scripts/prerender.ts`) +- `assets/` 를 통째로 지우지 않는다. 권한 때문에 지웠던 것인데 `copyDirectoryFiles` 가 + **파일마다** 먼저 `rmSync` 하므로 그 문제는 그대로 해결된다 +- `ASSET_RETENTION_DAYS`(30일) · `ASSET_MIN_BUILDS`(2) — 기간이 지나도 직전 빌드는 남는다 +- `out/assets/.builds.json` 대장: 어떤 빌드가 어떤 파일을 깔았는지. **mtime 으로 나이를 재지 + 않는다** — 복사·동기화가 시각을 갈아 버리면 옛 파일이 영원히 젊어지거나 산 파일이 지워진다. + 발행마다 이 함수가 도므로, 번들이 그대로면 줄을 늘리지 않고 맨 앞 줄의 시각만 갱신한다 +- 점(.)으로 시작해 `azure_static` 의 dotfile 필터에 걸러진다 — 대장은 업로드되지 않는다 + +**얻은 것** — 프론트 배포와 전체 재굽기가 **분리된다.** 재굽기를 안 하면 그 사이트만 옛 +디자인으로 뜬다(예전엔 깨졌다). AGENTS.md 의 ★규칙은 남지만 이유가 "안 하면 죽는다" 에서 +"안 하면 반영이 안 된다" 로 내려온다. + +**남은 것** — `azure_static._upload_shared` 가 매 발행마다 `assets/` 전체를 올린다. 보관 기간만큼 +업로드량이 는다. Azure 는 지금 꺼져 있으므로(DEPLOY.md) 켤 때 이미 있는 블롭을 건너뛰도록 고친다. + +**검증** — 실제로 세 번 구워 확인: 번들 해시가 바뀌어도 옛 파일 3개가 그대로 남고, 같은 번들로 +다시 구우면 대장이 늘지 않으며(2줄 유지), 대장의 마지막 줄을 60일 전으로 돌리자 그 빌드의 +파일 3개만 정리됐다. `tsc·eslint` 통과, `vitest` 22 passed. + +--- + ## 2026-09-07 — 사이트맵 lastmod 를 파일 mtime 에서 뗐다 **왜** diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index b6395e9..22941c6 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -479,18 +479,115 @@ function writeReport(payloadFile: string, report: RenderReport) { renameSync(tmp, target); } +/** + * 옛 자산 보관 기간. + * + * ★ 왜 지우지 않고 남기나 — HTML 은 자산 경로를 **파일명 해시까지 박아** 굽는다 + * (`/assets/index-DvNTmLhy.css`). 렌더러를 배포하면 이름이 바뀌는데, 그 순간 옛 파일을 + * 지우면 아직 다시 굽지 않은 사이트는 CSS·JS 가 **404** 다. 예전 구현이 그랬다. + * + * ★ 더 나쁜 건 크롤러다. 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌린다. 그 사이에 + * 자산이 사라지면 스타일도 스크립트도 없는 페이지를 렌더한 것으로 기록한다 — 하필 + * 신규 도메인이 평가받는 시기에 그렇게 된다. 유예 창이 필요하다는 게 업계 통념이고 + * (Vercel 은 검색봇에 한해 스큐 보호 창을 60일로 늘린다), 우리 창은 0초였다. + * + * 한 벌이 400KB 안팎이라 한 달치를 남겨도 10MB 남짓이다 — 싸게 사는 안전이다. + */ +const ASSET_RETENTION_DAYS = 30; +/** 하루에 여러 번 배포해도 직전 빌드는 반드시 남는다(보관 기간과 무관). */ +const ASSET_MIN_BUILDS = 2; +/** + * 어떤 빌드가 어떤 파일을 깔았는지. **보관 기간의 근거는 이 파일이다.** + * + * ★ 파일 mtime 으로 나이를 재지 않는다 — 복사·동기화가 시각을 갈아 버리면 옛 파일이 + * 영원히 젊어지거나 산 파일이 지워진다. 점(.)으로 시작해 업로드에서 빠진다 + * (azure_static 이 dotfile 을 거른다). + */ +const ASSET_LEDGER = '.builds.json'; + +/** 자산 대장 한 줄 — 한 번의 번들 빌드가 깐 파일 목록. */ +interface AssetBuild { + /** 이 번들을 마지막으로 깐 시각(ISO). 같은 번들로 다시 구우면 갱신된다. */ + at: string; + /** assets/ 기준 상대 경로. */ + files: string[]; +} + +/** 디렉토리 안의 파일을 상대 경로로 편다(하위 디렉토리 포함). */ +function listRelativeFiles(dir: string, prefix = ''): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, {withFileTypes: true})) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) found.push(...listRelativeFiles(join(dir, entry.name), rel)); + else if (entry.isFile()) found.push(rel); + } + return found; +} + +function readAssetLedger(assetsDir: string): AssetBuild[] { + try { + const parsed = JSON.parse(readFileSync(join(assetsDir, ASSET_LEDGER), 'utf-8')) as { + builds?: AssetBuild[]; + }; + return Array.isArray(parsed.builds) ? parsed.builds : []; + } catch { + // 없거나 깨졌으면 이번 빌드부터 다시 센다. 옛 파일은 그대로 남는다 — + // 대장을 못 읽었다고 지우는 쪽으로 실패하면 사이트가 깨진다. + return []; + } +} + +/** + * 보관 기간이 지난 옛 해시 파일만 지운다. + * + * ★ 발행할 때마다 이 함수가 돈다(사이트 하나만 구울 때도). 번들이 그대로면 대장에 줄이 + * 늘지 않고 맨 앞 줄의 시각만 갱신된다 — 안 그러면 발행 횟수만큼 대장이 자란다. + */ +function pruneAssets(assetsDir: string, current: string[]) { + const signature = (files: string[]) => [...files].sort().join('\n'); + const previous = readAssetLedger(assetsDir); + const head: AssetBuild = {at: new Date().toISOString(), files: current}; + const builds = + previous[0] && signature(previous[0].files) === signature(current) + ? [head, ...previous.slice(1)] + : [head, ...previous]; + + const cutoff = Date.now() - ASSET_RETENTION_DAYS * 24 * 60 * 60 * 1000; + const kept = builds.filter( + (build, index) => index < ASSET_MIN_BUILDS || Date.parse(build.at) >= cutoff, + ); + const alive = new Set(kept.flatMap((build) => build.files)); + + let removed = 0; + for (const file of listRelativeFiles(assetsDir)) { + if (file === ASSET_LEDGER || alive.has(file)) continue; + rmSync(join(assetsDir, file), {force: true}); + removed += 1; + } + + writeFileSync( + join(assetsDir, ASSET_LEDGER), + JSON.stringify({schemaVersion: 1, builds: kept}, null, 2), + 'utf-8', + ); + if (removed > 0) { + console.log(` ✓ 보관 기간(${ASSET_RETENTION_DAYS}일)이 지난 자산 ${removed}개 정리`); + } +} + /** * 클라이언트 번들과 public/ 을 대상 디렉토리에 깐다. * - * Docker bind mount 에서 이전 빌드가 남긴 파일 권한이 호스트와 어긋날 수 있어 - * assets/ 는 통째로 교체한다(낡은 해시 파일과 권한을 함께 제거). + * ★ assets/ 를 통째로 지우고 다시 깔지 않는다(예전 구현). 옛 해시 파일은 보관 기간까지 + * 남겨야 한다 — 근거는 ASSET_RETENTION_DAYS 주석. 권한 때문에 통째로 지웠던 것인데, + * copyDirectoryFiles 가 **파일마다** 먼저 rmSync 하므로 그 문제는 그대로 해결된다. */ function writeSharedAssets(destRoot: string) { const assetsSrc = join(CLIENT_DIR, 'assets'); if (existsSync(assetsSrc)) { const assetsDest = join(destRoot, 'assets'); - rmSync(assetsDest, {recursive: true, force: true}); copyDirectoryFiles(assetsSrc, assetsDest); + pruneAssets(assetsDest, listRelativeFiles(assetsSrc)); } const publicDir = join(SITE_ROOT, 'public'); if (existsSync(publicDir)) { From 292cf26fd945c3453ea43df5f748760444a4fcf7 Mon Sep 17 00:00:00 2001 From: Mina Choi <mnchoi@o2o.kr> Date: Mon, 7 Sep 2026 13:28:12 +0900 Subject: [PATCH 07/18] =?UTF-8?q?[fix]=20solution/site:=20=EB=8C=80?= =?UTF-8?q?=EC=9E=A5=EC=97=90=20=EC=97=86=EB=8A=94=20=EC=9E=90=EC=82=B0?= =?UTF-8?q?=EC=9D=84=20=EC=A7=80=EC=9A=B0=EC=A7=80=20=EC=95=8A=EB=8A=94?= =?UTF-8?q?=EB=8B=A4=20=E2=80=94=20=EC=B2=AB=20=EB=B0=B0=ED=8F=AC=EC=97=90?= =?UTF-8?q?=20=EC=9A=B4=EC=98=81=20=EC=82=AC=EC=9D=B4=ED=8A=B8=EA=B0=80=20?= =?UTF-8?q?=EB=81=8A=EA=B2=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 직전 커밋(옛 해시 자산 30일 보관)을 배포하자 기존 사이트의 CSS·JS 가 전부 404 가 됐다. 옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다. pruneAssets 가 "대장(.builds.json)에 없는 파일"을 만료로 보고 지웠다. 그런데 대장은 이 기능과 함께 처음 생긴다 — 배포 직후 첫 실행에는 대장이 없으므로, 디스크에 있던 기존 자산이 전부 "대장에 없음"으로 분류돼 한꺼번에 삭제됐다. 아직 다시 굽지 않은 사이트는 그 순간 죽는다. 검증을 out/ 을 비운 상태에서만 돌린 탓에 못 봤다 — 재현했어야 할 것은 빈 디렉토리가 아니라 "옛 자산은 있는데 대장은 없는" 상태, 즉 실제 배포 직전의 서버 모습이었다. - scripts/prerender.ts: 대장에 없는 파일은 "지금 처음 본 것"으로 입양해 보관 기간을 새로 준다 - AGENTS.md: "기록이 없다"와 "만료됐다"를 같이 묶지 않는다 — 함정 목록에 ★로 박았다 - docs/DEVLOG.md: 사고 기록과 복구 절차(docker compose restart solution-frontend) 검증: 배포 직전 상태 재현 — out/assets 에 옛 해시 파일만 두고 대장 없이 첫 실행하면 옛 파일이 그대로 남고 대장에 입양 항목으로 들어간다. 재실행해도 대장이 늘지 않는다. tsc·eslint 통과, vitest 22 passed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018xTWrJ6Mrr6HhEN6hZEER4 --- AGENTS.md | 7 +++++++ docs/DEVLOG.md | 26 ++++++++++++++++++++++++++ solution/site/scripts/prerender.ts | 25 ++++++++++++++++++++++--- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 784c678..7f17aec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,13 @@ 스타일 없는 페이지를 렌더한 것으로 기록된다. 보관 근거는 `out/assets/.builds.json` 대장이고 파일 mtime 이 아니다. → 재굽기는 여전히 필요하지만 **급하지 않다**. 디자인이 반영 안 될 뿐, 깨지지는 않는다. +- **★ 대장(`out/assets/.builds.json`)에 없는 자산은 지우지 않는다** — "지금 처음 본 것" 으로 + 치고 보관 기간을 새로 준다(`pruneAssets`). **이 규칙을 깨면 운영 사이트가 즉시 끊긴다.** + 실제로 그랬다(2026-09-07): 대장은 이 기능과 함께 생겼으므로 **배포 직후 첫 실행에는 대장이 + 없고**, 그때 디스크에 있던 기존 자산이 전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다. + 옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다. + → 자산을 지우는 코드를 손볼 때는 **"기록이 없다"와 "만료됐다"를 절대 같이 묶지 않는다.** + → 이미 끊겼다면 복구는 `docker compose restart solution-frontend` (기동하며 전체 재굽기). - **★ 프론트(`solution/site`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.** `azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 — **렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.** diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 0d33e6c..234c61f 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,32 @@ --- +## 2026-09-07 — (사고) 자산 보관 첫 배포에 운영 사이트 CSS 가 끊겼다 + +**무슨 일** +바로 아래 항목(옛 해시 자산 30일 보관)을 배포하자 **기존 사이트의 CSS·JS 가 전부 404** 가 됐다. +옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다. + +**왜** +`pruneAssets` 가 "대장(`.builds.json`)에 없는 파일" 을 만료로 보고 지웠다. 그런데 **대장은 이 +기능과 함께 처음 생긴다** — 배포 직후 첫 실행에는 대장이 없으므로, 디스크에 있던 기존 자산이 +전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다. 아직 다시 굽지 않은 사이트는 그 순간 죽는다. + +**놓친 것** — 검증을 `out/` 을 비운 상태에서만 돌렸다. 재현해야 했던 건 빈 디렉토리가 아니라 +**"옛 자산은 있는데 대장은 없는"** 상태, 즉 실제 배포 직전의 서버 모습이었다. + +**고친 것** (`scripts/prerender.ts` `pruneAssets`) +- 대장에 없는 파일은 지우지 않고 **"지금 처음 본 것" 으로 입양해** 보관 기간을 새로 준다 +- 규칙으로 굳혀 둔다: **"기록이 없다" 와 "만료됐다" 를 같이 묶지 않는다**(AGENTS.md 함정 목록) + +**복구** — `docker compose restart solution-frontend` (기동하며 전체 재굽기 → HTML 이 새 해시를 +가리킨다). 자산을 되살리는 게 아니라 HTML 을 새로 굽는 쪽이 빠르다. + +**검증** — 배포 직전 상태를 재현: `out/assets` 에 옛 해시 파일만 두고 대장 없이 첫 실행 → +옛 파일 2개가 그대로 남고 대장에 입양 항목으로 들어간다. 재실행해도 대장이 늘지 않는다. + +--- + ## 2026-09-07 — 옛 해시 자산을 30일 남긴다 — 배포와 재굽기를 뗀다 **왜** diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index 22941c6..495dc79 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -531,8 +531,8 @@ function readAssetLedger(assetsDir: string): AssetBuild[] { }; return Array.isArray(parsed.builds) ? parsed.builds : []; } catch { - // 없거나 깨졌으면 이번 빌드부터 다시 센다. 옛 파일은 그대로 남는다 — - // 대장을 못 읽었다고 지우는 쪽으로 실패하면 사이트가 깨진다. + // 없거나 깨졌으면 빈 대장으로 시작한다. 디스크에 있던 파일은 pruneAssets 가 + // "처음 본 것" 으로 입양하므로 지워지지 않는다 — 그 ★ 주석이 이 실패의 근거다. return []; } } @@ -545,13 +545,32 @@ function readAssetLedger(assetsDir: string): AssetBuild[] { */ function pruneAssets(assetsDir: string, current: string[]) { const signature = (files: string[]) => [...files].sort().join('\n'); + const now = new Date().toISOString(); const previous = readAssetLedger(assetsDir); - const head: AssetBuild = {at: new Date().toISOString(), files: current}; + const head: AssetBuild = {at: now, files: current}; const builds = previous[0] && signature(previous[0].files) === signature(current) ? [head, ...previous.slice(1)] : [head, ...previous]; + /** + * ★ 대장에 없는 파일은 **지우지 않는다.** 언제 깔렸는지 모를 뿐이므로 지금 처음 본 것으로 + * 치고 보관 기간을 새로 준다. + * + * 이 줄이 없어서 실제로 운영 사이트가 끊겼다(2026-09-07). 대장은 이 기능과 함께 생겼으니 + * **배포 직후 첫 실행에는 대장이 없다** — 그때 디스크에 있던 기존 자산이 전부 "대장에 없음" + * 으로 분류돼 한꺼번에 삭제됐고, 아직 다시 굽지 않은 사이트의 CSS 가 통째로 404 가 됐다. + * 옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다. + */ + const recorded = new Set(builds.flatMap((build) => build.files)); + const adopted = listRelativeFiles(assetsDir).filter( + (file) => file !== ASSET_LEDGER && !recorded.has(file), + ); + if (adopted.length > 0) { + builds.push({at: now, files: adopted}); + console.log(` ✓ 대장에 없던 자산 ${adopted.length}개를 보관 대상으로 넣는다`); + } + const cutoff = Date.now() - ASSET_RETENTION_DAYS * 24 * 60 * 60 * 1000; const kept = builds.filter( (build, index) => index < ASSET_MIN_BUILDS || Date.parse(build.at) >= cutoff, From 6f4e05517fef19a096e6cc0e97020fef8c73086f Mon Sep 17 00:00:00 2001 From: Mina Choi <mnchoi@o2o.kr> Date: Mon, 7 Sep 2026 13:30:11 +0900 Subject: [PATCH 08/18] =?UTF-8?q?[docs]=20AGENTS,deploy:=20=EC=9E=AC?= =?UTF-8?q?=EA=B5=BD=EA=B8=B0=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88?= =?UTF-8?q?=EB=8A=94=20solution-prerender=20=EB=8B=A4=20=E2=80=94=20soluti?= =?UTF-8?q?on-frontend=20=EB=8A=94=20=EA=B0=9C=EB=B0=9C=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문서 5곳이 `docker compose restart solution-frontend` 라고 적고 있었다. 그 서비스는 profiles: ["dev"] 라 운영에서는 뜨지 않는다 — 사이트를 굽는 건 solution-prerender 다. 이름이 비슷한 데다 명령이 실패하지도 않아서, 재굽기를 했다고 믿고 넘어가게 된다. 실제로 운영 사이트 CSS 복구가 이 명령으로 한 번 헛돌았다(2026-09-07). - AGENTS.md · docs/DEPLOY.md · docs/DEVLOG.md: 서비스명 교체 - AGENTS.md 함정 목록: 왜 조용히 틀리는지 ★로 박았다 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018xTWrJ6Mrr6HhEN6hZEER4 --- AGENTS.md | 8 ++++++-- docs/DEPLOY.md | 4 ++-- docs/DEVLOG.md | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7f17aec..88bda4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,11 +40,15 @@ 없고**, 그때 디스크에 있던 기존 자산이 전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다. 옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다. → 자산을 지우는 코드를 손볼 때는 **"기록이 없다"와 "만료됐다"를 절대 같이 묶지 않는다.** - → 이미 끊겼다면 복구는 `docker compose restart solution-frontend` (기동하며 전체 재굽기). + → 이미 끊겼다면 복구는 `docker compose restart solution-prerender` (기동하며 전체 재굽기). +- **★ 사이트를 굽는 컨테이너는 `solution-prerender` 다.** `solution-frontend` 는 **개발용**이라 + 운영에서는 아예 뜨지 않는다(`docker-compose.yml` `profiles: ["dev"]`). 이름이 비슷해서 + `restart solution-frontend` 를 치면 **아무 일도 안 일어나는데 명령은 성공한다** — + 재굽기를 했다고 믿고 넘어가게 된다. 실제로 그렇게 복구가 한 번 헛돌았다(2026-09-07). - **★ 프론트(`solution/site`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.** `azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 — **렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.** - → `docker compose restart solution-frontend` 후 `python scripts/republish_all.py` + → `docker compose restart solution-prerender` 후 `python scripts/republish_all.py` - **발행 호스트는 두 곳에 있고 같아야 한다.** 백엔드 `SITE_PUBLIC_HOST`(기본 `web4ai.o2osolution.ai`, `site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부 이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더 diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 9a27c1c..9c180f5 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -87,7 +87,7 @@ CSS 404 였다 — 하필 크롤러가 그 순간 렌더하면 스타일 없는 **규칙: `solution/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.** ```bash -docker compose restart solution-frontend # 기동하며 전체 재굽기 +docker compose restart solution-prerender # 기동하며 전체 재굽기 docker compose logs -f solution-frontend # "[watch] 기동" 배치가 끝날 때까지 대기 docker compose exec solution-worker python scripts/republish_all.py ``` @@ -225,5 +225,5 @@ docker compose exec solution-worker python scripts/republish_all.py ## 5. 되돌리기 `out/` 은 재생성물이라 백업이 필요 없다. 문제가 생기면 -`docker compose restart solution-frontend` → 전체 재굽기 → `republish_all.py`. +`docker compose restart solution-prerender` → 전체 재굽기 → `republish_all.py`. 지켜야 할 건 **DB 와 `out/payloads/`** 뿐이다. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 234c61f..2c42df4 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -23,7 +23,7 @@ - 대장에 없는 파일은 지우지 않고 **"지금 처음 본 것" 으로 입양해** 보관 기간을 새로 준다 - 규칙으로 굳혀 둔다: **"기록이 없다" 와 "만료됐다" 를 같이 묶지 않는다**(AGENTS.md 함정 목록) -**복구** — `docker compose restart solution-frontend` (기동하며 전체 재굽기 → HTML 이 새 해시를 +**복구** — `docker compose restart solution-prerender` (기동하며 전체 재굽기 → HTML 이 새 해시를 가리킨다). 자산을 되살리는 게 아니라 HTML 을 새로 굽는 쪽이 빠르다. **검증** — 배포 직전 상태를 재현: `out/assets` 에 옛 해시 파일만 두고 대장 없이 첫 실행 → From 22447ae041a5bb1a47ec5428f6bc51e89d207720 Mon Sep 17 00:00:00 2001 From: Mina Choi <mnchoi@o2o.kr> Date: Mon, 7 Sep 2026 13:33:34 +0900 Subject: [PATCH 09/18] =?UTF-8?q?Revert=20"[docs]=20AGENTS,deploy:=20?= =?UTF-8?q?=EC=9E=AC=EA=B5=BD=EA=B8=B0=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=EB=8A=94=20solution-prerender=20=EB=8B=A4=20=E2=80=94?= =?UTF-8?q?=20solution-frontend=20=EB=8A=94=20=EA=B0=9C=EB=B0=9C=EC=9A=A9"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 6f4e05517fef19a096e6cc0e97020fef8c73086f. --- AGENTS.md | 8 ++------ docs/DEPLOY.md | 4 ++-- docs/DEVLOG.md | 2 +- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 88bda4f..7f17aec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,15 +40,11 @@ 없고**, 그때 디스크에 있던 기존 자산이 전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다. 옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다. → 자산을 지우는 코드를 손볼 때는 **"기록이 없다"와 "만료됐다"를 절대 같이 묶지 않는다.** - → 이미 끊겼다면 복구는 `docker compose restart solution-prerender` (기동하며 전체 재굽기). -- **★ 사이트를 굽는 컨테이너는 `solution-prerender` 다.** `solution-frontend` 는 **개발용**이라 - 운영에서는 아예 뜨지 않는다(`docker-compose.yml` `profiles: ["dev"]`). 이름이 비슷해서 - `restart solution-frontend` 를 치면 **아무 일도 안 일어나는데 명령은 성공한다** — - 재굽기를 했다고 믿고 넘어가게 된다. 실제로 그렇게 복구가 한 번 헛돌았다(2026-09-07). + → 이미 끊겼다면 복구는 `docker compose restart solution-frontend` (기동하며 전체 재굽기). - **★ 프론트(`solution/site`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.** `azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 — **렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.** - → `docker compose restart solution-prerender` 후 `python scripts/republish_all.py` + → `docker compose restart solution-frontend` 후 `python scripts/republish_all.py` - **발행 호스트는 두 곳에 있고 같아야 한다.** 백엔드 `SITE_PUBLIC_HOST`(기본 `web4ai.o2osolution.ai`, `site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부 이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더 diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 9c180f5..9a27c1c 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -87,7 +87,7 @@ CSS 404 였다 — 하필 크롤러가 그 순간 렌더하면 스타일 없는 **규칙: `solution/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.** ```bash -docker compose restart solution-prerender # 기동하며 전체 재굽기 +docker compose restart solution-frontend # 기동하며 전체 재굽기 docker compose logs -f solution-frontend # "[watch] 기동" 배치가 끝날 때까지 대기 docker compose exec solution-worker python scripts/republish_all.py ``` @@ -225,5 +225,5 @@ docker compose exec solution-worker python scripts/republish_all.py ## 5. 되돌리기 `out/` 은 재생성물이라 백업이 필요 없다. 문제가 생기면 -`docker compose restart solution-prerender` → 전체 재굽기 → `republish_all.py`. +`docker compose restart solution-frontend` → 전체 재굽기 → `republish_all.py`. 지켜야 할 건 **DB 와 `out/payloads/`** 뿐이다. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 2c42df4..234c61f 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -23,7 +23,7 @@ - 대장에 없는 파일은 지우지 않고 **"지금 처음 본 것" 으로 입양해** 보관 기간을 새로 준다 - 규칙으로 굳혀 둔다: **"기록이 없다" 와 "만료됐다" 를 같이 묶지 않는다**(AGENTS.md 함정 목록) -**복구** — `docker compose restart solution-prerender` (기동하며 전체 재굽기 → HTML 이 새 해시를 +**복구** — `docker compose restart solution-frontend` (기동하며 전체 재굽기 → HTML 이 새 해시를 가리킨다). 자산을 되살리는 게 아니라 HTML 을 새로 굽는 쪽이 빠르다. **검증** — 배포 직전 상태를 재현: `out/assets` 에 옛 해시 파일만 두고 대장 없이 첫 실행 → From 9a4bc0e123b7f0832f30cebd92854f9f38037492 Mon Sep 17 00:00:00 2001 From: Mina Choi <mnchoi@o2o.kr> Date: Mon, 7 Sep 2026 13:33:34 +0900 Subject: [PATCH 10/18] =?UTF-8?q?Revert=20"[fix]=20solution/site:=20?= =?UTF-8?q?=EB=8C=80=EC=9E=A5=EC=97=90=20=EC=97=86=EB=8A=94=20=EC=9E=90?= =?UTF-8?q?=EC=82=B0=EC=9D=84=20=EC=A7=80=EC=9A=B0=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=EB=8B=A4=20=E2=80=94=20=EC=B2=AB=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=EC=97=90=20=EC=9A=B4=EC=98=81=20=EC=82=AC=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EA=B0=80=20=EB=81=8A=EA=B2=BC=EB=8B=A4"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 292cf26fd945c3453ea43df5f748760444a4fcf7. --- AGENTS.md | 7 ------- docs/DEVLOG.md | 26 -------------------------- solution/site/scripts/prerender.ts | 25 +++---------------------- 3 files changed, 3 insertions(+), 55 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7f17aec..784c678 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,13 +34,6 @@ 스타일 없는 페이지를 렌더한 것으로 기록된다. 보관 근거는 `out/assets/.builds.json` 대장이고 파일 mtime 이 아니다. → 재굽기는 여전히 필요하지만 **급하지 않다**. 디자인이 반영 안 될 뿐, 깨지지는 않는다. -- **★ 대장(`out/assets/.builds.json`)에 없는 자산은 지우지 않는다** — "지금 처음 본 것" 으로 - 치고 보관 기간을 새로 준다(`pruneAssets`). **이 규칙을 깨면 운영 사이트가 즉시 끊긴다.** - 실제로 그랬다(2026-09-07): 대장은 이 기능과 함께 생겼으므로 **배포 직후 첫 실행에는 대장이 - 없고**, 그때 디스크에 있던 기존 자산이 전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다. - 옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다. - → 자산을 지우는 코드를 손볼 때는 **"기록이 없다"와 "만료됐다"를 절대 같이 묶지 않는다.** - → 이미 끊겼다면 복구는 `docker compose restart solution-frontend` (기동하며 전체 재굽기). - **★ 프론트(`solution/site`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.** `azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 — **렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.** diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 234c61f..0d33e6c 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,32 +5,6 @@ --- -## 2026-09-07 — (사고) 자산 보관 첫 배포에 운영 사이트 CSS 가 끊겼다 - -**무슨 일** -바로 아래 항목(옛 해시 자산 30일 보관)을 배포하자 **기존 사이트의 CSS·JS 가 전부 404** 가 됐다. -옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다. - -**왜** -`pruneAssets` 가 "대장(`.builds.json`)에 없는 파일" 을 만료로 보고 지웠다. 그런데 **대장은 이 -기능과 함께 처음 생긴다** — 배포 직후 첫 실행에는 대장이 없으므로, 디스크에 있던 기존 자산이 -전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다. 아직 다시 굽지 않은 사이트는 그 순간 죽는다. - -**놓친 것** — 검증을 `out/` 을 비운 상태에서만 돌렸다. 재현해야 했던 건 빈 디렉토리가 아니라 -**"옛 자산은 있는데 대장은 없는"** 상태, 즉 실제 배포 직전의 서버 모습이었다. - -**고친 것** (`scripts/prerender.ts` `pruneAssets`) -- 대장에 없는 파일은 지우지 않고 **"지금 처음 본 것" 으로 입양해** 보관 기간을 새로 준다 -- 규칙으로 굳혀 둔다: **"기록이 없다" 와 "만료됐다" 를 같이 묶지 않는다**(AGENTS.md 함정 목록) - -**복구** — `docker compose restart solution-frontend` (기동하며 전체 재굽기 → HTML 이 새 해시를 -가리킨다). 자산을 되살리는 게 아니라 HTML 을 새로 굽는 쪽이 빠르다. - -**검증** — 배포 직전 상태를 재현: `out/assets` 에 옛 해시 파일만 두고 대장 없이 첫 실행 → -옛 파일 2개가 그대로 남고 대장에 입양 항목으로 들어간다. 재실행해도 대장이 늘지 않는다. - ---- - ## 2026-09-07 — 옛 해시 자산을 30일 남긴다 — 배포와 재굽기를 뗀다 **왜** diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index 495dc79..22941c6 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -531,8 +531,8 @@ function readAssetLedger(assetsDir: string): AssetBuild[] { }; return Array.isArray(parsed.builds) ? parsed.builds : []; } catch { - // 없거나 깨졌으면 빈 대장으로 시작한다. 디스크에 있던 파일은 pruneAssets 가 - // "처음 본 것" 으로 입양하므로 지워지지 않는다 — 그 ★ 주석이 이 실패의 근거다. + // 없거나 깨졌으면 이번 빌드부터 다시 센다. 옛 파일은 그대로 남는다 — + // 대장을 못 읽었다고 지우는 쪽으로 실패하면 사이트가 깨진다. return []; } } @@ -545,32 +545,13 @@ function readAssetLedger(assetsDir: string): AssetBuild[] { */ function pruneAssets(assetsDir: string, current: string[]) { const signature = (files: string[]) => [...files].sort().join('\n'); - const now = new Date().toISOString(); const previous = readAssetLedger(assetsDir); - const head: AssetBuild = {at: now, files: current}; + const head: AssetBuild = {at: new Date().toISOString(), files: current}; const builds = previous[0] && signature(previous[0].files) === signature(current) ? [head, ...previous.slice(1)] : [head, ...previous]; - /** - * ★ 대장에 없는 파일은 **지우지 않는다.** 언제 깔렸는지 모를 뿐이므로 지금 처음 본 것으로 - * 치고 보관 기간을 새로 준다. - * - * 이 줄이 없어서 실제로 운영 사이트가 끊겼다(2026-09-07). 대장은 이 기능과 함께 생겼으니 - * **배포 직후 첫 실행에는 대장이 없다** — 그때 디스크에 있던 기존 자산이 전부 "대장에 없음" - * 으로 분류돼 한꺼번에 삭제됐고, 아직 다시 굽지 않은 사이트의 CSS 가 통째로 404 가 됐다. - * 옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다. - */ - const recorded = new Set(builds.flatMap((build) => build.files)); - const adopted = listRelativeFiles(assetsDir).filter( - (file) => file !== ASSET_LEDGER && !recorded.has(file), - ); - if (adopted.length > 0) { - builds.push({at: now, files: adopted}); - console.log(` ✓ 대장에 없던 자산 ${adopted.length}개를 보관 대상으로 넣는다`); - } - const cutoff = Date.now() - ASSET_RETENTION_DAYS * 24 * 60 * 60 * 1000; const kept = builds.filter( (build, index) => index < ASSET_MIN_BUILDS || Date.parse(build.at) >= cutoff, From b797535b178c2c0c2c464e0ba064eafc9c9ddcbf Mon Sep 17 00:00:00 2001 From: Mina Choi <mnchoi@o2o.kr> Date: Mon, 7 Sep 2026 13:33:34 +0900 Subject: [PATCH 11/18] =?UTF-8?q?Revert=20"[fix]=20solution/site:=20?= =?UTF-8?q?=EC=98=9B=20=ED=95=B4=EC=8B=9C=20=EC=9E=90=EC=82=B0=EC=9D=84=20?= =?UTF-8?q?30=EC=9D=BC=20=EB=82=A8=EA=B8=B4=EB=8B=A4=20=E2=80=94=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=EC=99=80=20=EC=A0=84=EC=B2=B4=20=EC=9E=AC?= =?UTF-8?q?=EA=B5=BD=EA=B8=B0=EB=A5=BC=20=EB=97=80=EB=8B=A4"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 8f6ea16f6595107636a5c4366e569cbcee9ce227. --- AGENTS.md | 9 +-- docs/DEPLOY.md | 20 ++---- docs/DEVLOG.md | 35 ---------- solution/site/scripts/prerender.ts | 103 +---------------------------- 4 files changed, 11 insertions(+), 156 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 784c678..5652113 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,12 +28,9 @@ - **번들 파일명은 콘텐츠 해시다.** HTML 은 `/assets/index-DvNTmLhy.css` 를 **루트 절대경로**로 가리킨다. 경로는 프리렌더가 `dist/client/.vite/manifest.json` 에서 읽어 박는다 (`prerender.ts:160`). 렌더러 CSS 를 고치면 이름이 바뀐다. -- **옛 해시 자산은 30일 남는다** (`prerender.ts` `ASSET_RETENTION_DAYS`). 예전에는 빌드마다 - `out/assets` 를 통째로 갈아서, 새 번들로 일부만 구우면 나머지 사이트가 CSS 404 였다. - 지금은 남긴다 — 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌리므로, 그 사이 자산이 사라지면 - 스타일 없는 페이지를 렌더한 것으로 기록된다. 보관 근거는 `out/assets/.builds.json` 대장이고 - 파일 mtime 이 아니다. - → 재굽기는 여전히 필요하지만 **급하지 않다**. 디자인이 반영 안 될 뿐, 깨지지는 않는다. +- **로컬 `out/assets` 는 빌드마다 통째로 갈린다** (`prerender.ts:573` `rmSync`). 옛 해시 파일이 + 사라지므로 새 번들로 일부 사이트만 구우면 나머지는 CSS 가 404 다. + → 프리렌더 기동 시 전체 재굽기가 이 구멍을 메운다. - **★ 프론트(`solution/site`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.** `azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 — **렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.** diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 9a27c1c..2490f3b 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -66,23 +66,13 @@ site-out/ ``` 프론트 수정 → 새 번들(새 해시) - 로컬 out/assets : 새 해시 추가, 옛 해시 30일 보관 ← 안 깨진다. 옛 디자인으로 뜰 뿐 - Azure : 새 해시 추가, 옛 해시 유지 ← 같다 + 로컬 out/assets : 통째로 교체 (옛 해시 삭제) ← 재굽기 안 한 사이트는 CSS 404 + Azure : 새 해시 추가, 옛 해시 유지 ← 안 깨지지만 옛 디자인 그대로 박제 ``` -**2026-09-07 이전에는 로컬 `out/assets` 를 통째로 갈았다.** 그래서 재굽기 전까지 나머지 사이트가 -CSS 404 였다 — 하필 크롤러가 그 순간 렌더하면 스타일 없는 페이지를 본 것으로 기록된다. -지금은 `ASSET_RETENTION_DAYS`(30일) 동안 옛 해시를 남긴다. 보관 근거는 `out/assets/.builds.json` -대장이다(파일 mtime 이 아니다 — 복사·동기화가 시각을 갈아 버린다). - -**그래서 재굽기는 여전히 필요하지만 급하지는 않다.** 안 하면 그 사이트만 옛 디자인으로 뜬다. -프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 하지만 Azure 는 발행 잡이 도는 -사이트 하나씩만 올린다 — 그 짝을 맞추는 게 `solution/backend/scripts/republish_all.py` 다. - -⚠️ 남은 것: `azure_static._upload_shared` 는 매 발행마다 `assets/` **전체**를 다시 올린다. -옛 해시를 남기기 시작했으므로 보관 기간만큼 업로드량이 는다. Azure 를 켤 때는 이미 있는 -블롭(해시 파일이라 이름이 같으면 내용도 같다)을 건너뛰도록 먼저 고친다. - +프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 그래서 로컬 out/ 은 재시작만 +하면 정합이 맞는다. 하지만 Azure 는 발행 잡이 도는 사이트 하나씩만 올린다 — 그 짝을 맞추는 게 +`solution/backend/scripts/republish_all.py` 다. **규칙: `solution/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.** diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 0d33e6c..f56c53f 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,41 +5,6 @@ --- -## 2026-09-07 — 옛 해시 자산을 30일 남긴다 — 배포와 재굽기를 뗀다 - -**왜** -`writeSharedAssets` 가 빌드마다 `out/assets` 를 통째로 지우고 다시 깔았다. HTML 은 자산 경로를 -파일명 해시까지 박아 굽기 때문에, 렌더러를 배포하는 순간 **아직 다시 굽지 않은 사이트는 전부 -CSS·JS 404** 였다. 구멍을 "기동 시 전체 재굽기" 와 "배포하면 반드시 전체 재업로드" 라는 **규칙** -으로 막고 있었다 — 규칙으로 막는다는 건 구조가 못 막는다는 뜻이다. - -진짜 위험은 방문자가 아니라 크롤러다. 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌린다. -그 사이에 자산이 사라지면 스타일도 스크립트도 없는 페이지를 렌더한 것으로 기록한다 — -하필 지금이 신규 도메인이 평가받는 시기다. 유예 창이 필요하다는 건 업계 통념이고 -(Vercel 은 검색봇에 한해 스큐 보호 창을 60일로 늘린다), 우리 창은 0초였다. - -**바꾼 것** (`scripts/prerender.ts`) -- `assets/` 를 통째로 지우지 않는다. 권한 때문에 지웠던 것인데 `copyDirectoryFiles` 가 - **파일마다** 먼저 `rmSync` 하므로 그 문제는 그대로 해결된다 -- `ASSET_RETENTION_DAYS`(30일) · `ASSET_MIN_BUILDS`(2) — 기간이 지나도 직전 빌드는 남는다 -- `out/assets/.builds.json` 대장: 어떤 빌드가 어떤 파일을 깔았는지. **mtime 으로 나이를 재지 - 않는다** — 복사·동기화가 시각을 갈아 버리면 옛 파일이 영원히 젊어지거나 산 파일이 지워진다. - 발행마다 이 함수가 도므로, 번들이 그대로면 줄을 늘리지 않고 맨 앞 줄의 시각만 갱신한다 -- 점(.)으로 시작해 `azure_static` 의 dotfile 필터에 걸러진다 — 대장은 업로드되지 않는다 - -**얻은 것** — 프론트 배포와 전체 재굽기가 **분리된다.** 재굽기를 안 하면 그 사이트만 옛 -디자인으로 뜬다(예전엔 깨졌다). AGENTS.md 의 ★규칙은 남지만 이유가 "안 하면 죽는다" 에서 -"안 하면 반영이 안 된다" 로 내려온다. - -**남은 것** — `azure_static._upload_shared` 가 매 발행마다 `assets/` 전체를 올린다. 보관 기간만큼 -업로드량이 는다. Azure 는 지금 꺼져 있으므로(DEPLOY.md) 켤 때 이미 있는 블롭을 건너뛰도록 고친다. - -**검증** — 실제로 세 번 구워 확인: 번들 해시가 바뀌어도 옛 파일 3개가 그대로 남고, 같은 번들로 -다시 구우면 대장이 늘지 않으며(2줄 유지), 대장의 마지막 줄을 60일 전으로 돌리자 그 빌드의 -파일 3개만 정리됐다. `tsc·eslint` 통과, `vitest` 22 passed. - ---- - ## 2026-09-07 — 사이트맵 lastmod 를 파일 mtime 에서 뗐다 **왜** diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index 22941c6..b6395e9 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -479,115 +479,18 @@ function writeReport(payloadFile: string, report: RenderReport) { renameSync(tmp, target); } -/** - * 옛 자산 보관 기간. - * - * ★ 왜 지우지 않고 남기나 — HTML 은 자산 경로를 **파일명 해시까지 박아** 굽는다 - * (`/assets/index-DvNTmLhy.css`). 렌더러를 배포하면 이름이 바뀌는데, 그 순간 옛 파일을 - * 지우면 아직 다시 굽지 않은 사이트는 CSS·JS 가 **404** 다. 예전 구현이 그랬다. - * - * ★ 더 나쁜 건 크롤러다. 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌린다. 그 사이에 - * 자산이 사라지면 스타일도 스크립트도 없는 페이지를 렌더한 것으로 기록한다 — 하필 - * 신규 도메인이 평가받는 시기에 그렇게 된다. 유예 창이 필요하다는 게 업계 통념이고 - * (Vercel 은 검색봇에 한해 스큐 보호 창을 60일로 늘린다), 우리 창은 0초였다. - * - * 한 벌이 400KB 안팎이라 한 달치를 남겨도 10MB 남짓이다 — 싸게 사는 안전이다. - */ -const ASSET_RETENTION_DAYS = 30; -/** 하루에 여러 번 배포해도 직전 빌드는 반드시 남는다(보관 기간과 무관). */ -const ASSET_MIN_BUILDS = 2; -/** - * 어떤 빌드가 어떤 파일을 깔았는지. **보관 기간의 근거는 이 파일이다.** - * - * ★ 파일 mtime 으로 나이를 재지 않는다 — 복사·동기화가 시각을 갈아 버리면 옛 파일이 - * 영원히 젊어지거나 산 파일이 지워진다. 점(.)으로 시작해 업로드에서 빠진다 - * (azure_static 이 dotfile 을 거른다). - */ -const ASSET_LEDGER = '.builds.json'; - -/** 자산 대장 한 줄 — 한 번의 번들 빌드가 깐 파일 목록. */ -interface AssetBuild { - /** 이 번들을 마지막으로 깐 시각(ISO). 같은 번들로 다시 구우면 갱신된다. */ - at: string; - /** assets/ 기준 상대 경로. */ - files: string[]; -} - -/** 디렉토리 안의 파일을 상대 경로로 편다(하위 디렉토리 포함). */ -function listRelativeFiles(dir: string, prefix = ''): string[] { - const found: string[] = []; - for (const entry of readdirSync(dir, {withFileTypes: true})) { - const rel = prefix ? `${prefix}/${entry.name}` : entry.name; - if (entry.isDirectory()) found.push(...listRelativeFiles(join(dir, entry.name), rel)); - else if (entry.isFile()) found.push(rel); - } - return found; -} - -function readAssetLedger(assetsDir: string): AssetBuild[] { - try { - const parsed = JSON.parse(readFileSync(join(assetsDir, ASSET_LEDGER), 'utf-8')) as { - builds?: AssetBuild[]; - }; - return Array.isArray(parsed.builds) ? parsed.builds : []; - } catch { - // 없거나 깨졌으면 이번 빌드부터 다시 센다. 옛 파일은 그대로 남는다 — - // 대장을 못 읽었다고 지우는 쪽으로 실패하면 사이트가 깨진다. - return []; - } -} - -/** - * 보관 기간이 지난 옛 해시 파일만 지운다. - * - * ★ 발행할 때마다 이 함수가 돈다(사이트 하나만 구울 때도). 번들이 그대로면 대장에 줄이 - * 늘지 않고 맨 앞 줄의 시각만 갱신된다 — 안 그러면 발행 횟수만큼 대장이 자란다. - */ -function pruneAssets(assetsDir: string, current: string[]) { - const signature = (files: string[]) => [...files].sort().join('\n'); - const previous = readAssetLedger(assetsDir); - const head: AssetBuild = {at: new Date().toISOString(), files: current}; - const builds = - previous[0] && signature(previous[0].files) === signature(current) - ? [head, ...previous.slice(1)] - : [head, ...previous]; - - const cutoff = Date.now() - ASSET_RETENTION_DAYS * 24 * 60 * 60 * 1000; - const kept = builds.filter( - (build, index) => index < ASSET_MIN_BUILDS || Date.parse(build.at) >= cutoff, - ); - const alive = new Set(kept.flatMap((build) => build.files)); - - let removed = 0; - for (const file of listRelativeFiles(assetsDir)) { - if (file === ASSET_LEDGER || alive.has(file)) continue; - rmSync(join(assetsDir, file), {force: true}); - removed += 1; - } - - writeFileSync( - join(assetsDir, ASSET_LEDGER), - JSON.stringify({schemaVersion: 1, builds: kept}, null, 2), - 'utf-8', - ); - if (removed > 0) { - console.log(` ✓ 보관 기간(${ASSET_RETENTION_DAYS}일)이 지난 자산 ${removed}개 정리`); - } -} - /** * 클라이언트 번들과 public/ 을 대상 디렉토리에 깐다. * - * ★ assets/ 를 통째로 지우고 다시 깔지 않는다(예전 구현). 옛 해시 파일은 보관 기간까지 - * 남겨야 한다 — 근거는 ASSET_RETENTION_DAYS 주석. 권한 때문에 통째로 지웠던 것인데, - * copyDirectoryFiles 가 **파일마다** 먼저 rmSync 하므로 그 문제는 그대로 해결된다. + * Docker bind mount 에서 이전 빌드가 남긴 파일 권한이 호스트와 어긋날 수 있어 + * assets/ 는 통째로 교체한다(낡은 해시 파일과 권한을 함께 제거). */ function writeSharedAssets(destRoot: string) { const assetsSrc = join(CLIENT_DIR, 'assets'); if (existsSync(assetsSrc)) { const assetsDest = join(destRoot, 'assets'); + rmSync(assetsDest, {recursive: true, force: true}); copyDirectoryFiles(assetsSrc, assetsDest); - pruneAssets(assetsDest, listRelativeFiles(assetsSrc)); } const publicDir = join(SITE_ROOT, 'public'); if (existsSync(publicDir)) { From de5ff5186fafe58b77965ddfba4ddd00ebf3f974 Mon Sep 17 00:00:00 2001 From: Mina Choi <mnchoi@o2o.kr> Date: Mon, 7 Sep 2026 13:33:34 +0900 Subject: [PATCH 12/18] =?UTF-8?q?Revert=20"[fix]=20solution/site:=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=8A=B8=EB=A7=B5=20lastmod=20=EB=A5=BC=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20mtime=20=EC=97=90=EC=84=9C=20=EB=97=80?= =?UTF-8?q?=EB=8B=A4=20=E2=80=94=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=9D=98=20?= =?UTF-8?q?dateModified=20=EB=A5=BC=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EC=93=B4=EB=8B=A4"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 09b0538c9c89ca0d5867426b138eb0a4ea5a9897. --- docs/DEVLOG.md | 27 --------------- solution/site/scripts/prerender.ts | 35 ++++++++++--------- solution/site/src/seo/directory.test.ts | 46 ------------------------- solution/site/src/seo/directory.ts | 32 ----------------- 4 files changed, 18 insertions(+), 122 deletions(-) delete mode 100644 solution/site/src/seo/directory.test.ts diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index f56c53f..9541b9e 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,33 +5,6 @@ --- -## 2026-09-07 — 사이트맵 lastmod 를 파일 mtime 에서 뗐다 - -**왜** -`lastmod` 를 구운 `index.html` 의 **파일 mtime** 에서 읽고 있었다. 그런데 렌더러를 배포하면 -번들 해시가 바뀌어 **내용이 한 글자도 안 바뀐 사이트까지 전부 다시 구워진다** — mtime 은 -그때마다 오늘이 되고, 사이트맵은 "전 사이트가 오늘 갱신됨" 을 통보한다. - -구글은 lastmod 를 페이지의 실제 수정과 대조해 맞을 때만 쓰고, 어긋나면 **그 필드를 아예 -무시한다**(Search Central: "the date and time of the last significant update" · -"consistently and verifiably accurate"). 즉 이 오염은 지금 당장 뭘 깨뜨리는 게 아니라, -**사장님이 진짜로 내용을 고쳐 재발행한 날의 신호를 미리 죽여 두는** 종류다. 배포할 때마다 -신뢰를 태우고 있었고, 사이트가 100개를 넘기면 되돌리는 데 시간이 걸린다. - -**바꾼 것** -- `seo/directory.ts`: `readBakedTitle` · `readBakedLastmod` — 구운 HTML 에서 목록·사이트맵 - 값을 꺼낸다. lastmod 는 페이지가 head 에 선언한 `dateModified`(= `payload.site.updatedAt`) - **그 값 그대로**다. 구글이 대조하는 값과 글자 그대로 같아 어긋날 수가 없다 -- `scripts/prerender.ts`: `readTitle` 을 위로 옮기고 사이트맵 항목에서 mtime 제거. 파일을 - 한 번만 읽어 제목과 lastmod 를 같이 꺼낸다. mtime 은 `dateModified` 메타가 없던 시절의 - 산출물에만 남는 폴백이다 — 그 사이트를 한 번 다시 구우면 제 값이 들어온다 -- `seo/directory.test.ts`: head.ts 의 메타와 파서의 **커플링을 고정**한다. 태그 모양이 바뀌면 - 파서가 조용히 undefined 를 내고 mtime 으로 되돌아간다 — 빌드도 화면도 멀쩡한 회귀라서 붙였다 - -**검증** — `tsc·eslint` 통과, `vitest` 22 passed (신규 5건). - ---- - ## 2026-09-03 — 레포·발행 호스트 교체 — `o2o-site-AEO` / `web4ai.o2osolution.ai` **왜** diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index b6395e9..e8ab11d 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -22,8 +22,6 @@ import {render} from '@/entry-server'; import { collectJsonLd, homeMeta, - readBakedLastmod, - readBakedTitle, renderHead, renderLlmsTxt, renderRootLlmsTxt, @@ -520,21 +518,18 @@ function writeRootMachineFiles(outRoot: string, origin: string) { .map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')})) // index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. 사이트맵에 넣지 않는다. .filter((entry) => existsSync(entry.file)) - .map((entry) => { - // 제목과 lastmod 가 같은 HTML 에서 나온다 — 파일은 한 번만 읽는다. - const html = readFileSync(entry.file, 'utf-8'); - return { - // ★ 끝 슬래시를 붙이지 않는다. 페이지의 canonical 은 `/s/<slug>` 다(shared/lib/slug.ts - // publishUrl). 사이트맵이 `/s/<slug>/` 로 어긋나 있던 동안 서치콘솔은 제출한 URL 을 - // 전부 "대체 페이지(적절한 표준 태그가 있음)" 로 분류했다 — 색인은 되는데 제출분은 - // 0건으로 보이는, 눈으로 원인을 못 찾는 종류다. - loc: joinUrl(origin, SITE_DIR, entry.slug), - title: readBakedTitle(html) || entry.slug, - // ★ mtime 으로 떨어지는 건 dateModified 메타가 없던 시절의 산출물뿐이다. - // 그 사이트를 한 번 다시 구우면 제 값이 들어온다(readBakedLastmod 주석 참조). - lastmod: readBakedLastmod(html) ?? statSync(entry.file).mtime.toISOString(), - }; - }) + .map((entry) => ({ + // ★ 끝 슬래시를 붙이지 않는다. 페이지의 canonical 은 `/s/<slug>` 다(shared/lib/slug.ts + // publishUrl). 사이트맵이 `/s/<slug>/` 로 어긋나 있던 동안 서치콘솔은 제출한 URL 을 + // 전부 "대체 페이지(적절한 표준 태그가 있음)" 로 분류했다 — 색인은 되는데 제출분은 + // 0건으로 보이는, 눈으로 원인을 못 찾는 종류다. + loc: joinUrl(origin, SITE_DIR, entry.slug), + // 제목은 구운 HTML 에서 읽는다. payload 에서 가져오면 이번 실행분만 이름이 있고 + // 나머지는 슬러그로 떨어진다 — 발행은 바뀐 사이트 하나만 굽기 때문이다. + title: readTitle(entry.file) || entry.slug, + // 페이지는 그 사이트를 구울 때마다 다시 쓰인다 — 파일 mtime 이 곧 마지막 발행 시각이다. + lastmod: statSync(entry.file).mtime.toISOString(), + })) .sort((a, b) => a.loc.localeCompare(b.loc)); // ★ `/s/` 목록 페이지. 크롤러가 발행본에 닿는 두 번째 경로다 — @@ -554,6 +549,12 @@ function writeRootMachineFiles(outRoot: string, origin: string) { writeIndexNowKey(outRoot); } +/** 구운 index.html 에서 <title> 만 꺼낸다. 파서를 붙일 값어치가 없는 한 줄짜리 일이다. */ +function readTitle(file: string): string { + const match = /<title>([^<]*)<\/title>/.exec(readFileSync(file, 'utf-8')); + return match ? match[1].trim() : ''; +} + /** * IndexNow 키 파일 — `https://<host>/<key>.txt` 에 키 문자열만 들어 있다. * diff --git a/solution/site/src/seo/directory.test.ts b/solution/site/src/seo/directory.test.ts deleted file mode 100644 index 958cedf..0000000 --- a/solution/site/src/seo/directory.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * 구운 HTML → 사이트맵·목록 값. - * - * ★ 이 테스트가 지키는 것은 **커플링 하나**다. 사이트맵의 lastmod 는 페이지가 head 에 - * 선언한 dateModified 와 같은 값이어야 한다 — 구글이 lastmod 를 신뢰하는 조건이 - * "페이지의 실제 수정과 대조해 맞을 것" 이기 때문이다. head.ts 가 태그 모양을 바꾸면 - * readBakedLastmod 는 조용히 undefined 를 내고 사이트맵은 mtime 으로 되돌아간다 — - * 빌드는 통과하고 화면도 멀쩡한, 눈으로 못 찾는 종류의 회귀다. 그래서 붙인다. - */ -import {describe, expect, it} from 'vitest'; - -import {MOONLIGHT_STAY_PAYLOAD} from '../fixtures/moonlight-stay'; -import {readBakedLastmod, readBakedTitle} from './directory'; -import {renderHead} from './head'; -import {homeMeta} from './meta'; - -/** 실제 프리렌더와 같은 경로로 head 를 굽는다(prerender.ts prerenderSite). */ -function bakedHead(): string { - const payload = MOONLIGHT_STAY_PAYLOAD; - return renderHead({payload, meta: homeMeta(payload)}); -} - -describe('readBakedLastmod', () => { - it('head 가 선언한 dateModified 를 그대로 돌려준다', () => { - expect(readBakedLastmod(bakedHead())).toBe(MOONLIGHT_STAY_PAYLOAD.site.updatedAt); - }); - - it('메타가 없으면 undefined — 호출부가 mtime 으로 떨어진다', () => { - expect(readBakedLastmod('<html><head><title>x')).toBeUndefined(); - }); - - it('날짜로 못 읽는 값은 버린다 — 틀린 lastmod 는 없는 것보다 나쁘다', () => { - expect(readBakedLastmod('')).toBeUndefined(); - expect(readBakedLastmod('')).toBeUndefined(); - }); -}); - -describe('readBakedTitle', () => { - it('구운 HTML 의 title 을 꺼낸다', () => { - expect(readBakedTitle(bakedHead())).toBe(homeMeta(MOONLIGHT_STAY_PAYLOAD).title); - }); - - it('title 이 없으면 빈 문자열 — 호출부가 슬러그로 떨어진다', () => { - expect(readBakedTitle('')).toBe(''); - }); -}); diff --git a/solution/site/src/seo/directory.ts b/solution/site/src/seo/directory.ts index 1dde43c..6c87b9d 100644 --- a/solution/site/src/seo/directory.ts +++ b/solution/site/src/seo/directory.ts @@ -9,38 +9,6 @@ export interface DirectoryEntry { lastmod?: string; } -/** - * 구운 `index.html` 에서 `` 만 꺼낸다. 파서를 붙일 값어치가 없는 한 줄짜리 일이다. - * - * ★ 왜 payload 가 아니라 구운 HTML 을 읽나 — 발행은 **바뀐 사이트 하나만** 굽는다. - * 이번 실행분으로만 목록을 만들면 나머지 사이트가 목록에서 사라진다. - */ -export function readBakedTitle(html: string): string { - const match = /<title>([^<]*)<\/title>/.exec(html); - return match ? match[1].trim() : ''; -} - -/** - * 사이트맵 `lastmod` — **페이지가 스스로 선언한 `dateModified` 를 그대로** 쓴다. - * - * ★ 파일 mtime 을 쓰면 안 된다(예전 구현). 렌더러를 배포하면 번들 해시가 바뀌어 내용이 - * 같은 사이트까지 전부 다시 구워진다 — mtime 은 그때마다 오늘이 되고, 사이트맵은 - * **"전 사이트가 오늘 갱신됨"** 을 통보한다. 구글은 lastmod 를 페이지의 실제 수정과 - * 대조해 맞을 때만 쓰고 어긋나면 그 필드를 **아예 무시한다**(Search Central: "the date and - * time of the last significant update" · "consistently and verifiably accurate"). - * 즉 이 오염은 사장님이 **진짜로** 내용을 고쳐 재발행한 날의 신호까지 같이 죽인다. - * - * ★ head.ts 의 `dateModified` 메타와 **같은 값**을 읽는다 — 구글이 대조하는 그 값이라 - * 사이트맵과 페이지가 어긋날 수 없다. head.ts 가 이 태그를 바꾸면 여기도 같이 고친다 - * (directory.test.ts 가 그 커플링을 고정해 둔다). - */ -export function readBakedLastmod(html: string): string | undefined { - const match = /<meta name="dateModified" content="([^"]*)"/.exec(html); - const value = match?.[1].trim(); - // 값이 깨졌으면 넣지 않는다 — 틀린 lastmod 는 없는 것보다 나쁘다(위 ★ 참조). - return value && !Number.isNaN(Date.parse(value)) ? value : undefined; -} - /** * `/s/` 발행 사이트 목록 페이지. * From f008b2457459686756c76b2d2554a08e458e0553 Mon Sep 17 00:00:00 2001 From: Mina Choi <mnchoi@o2o.kr> Date: Mon, 7 Sep 2026 14:04:50 +0900 Subject: [PATCH 13/18] =?UTF-8?q?[fix]=20solution/site:=20=EC=9E=90?= =?UTF-8?q?=EC=82=B0=20=EB=B3=B4=EA=B4=80=20=EC=BD=94=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=90=98=EC=82=B4=EB=A6=B0=EB=8B=A4=20=E2=80=94=20=EB=AA=A9?= =?UTF-8?q?=EC=97=85=EC=9D=80=20=EC=9E=AC=EA=B5=BD=EA=B8=B0=EA=B0=80=20?= =?UTF-8?q?=EC=95=88=20=EB=90=98=EB=AF=80=EB=A1=9C=20=EC=9E=90=EC=82=B0?= =?UTF-8?q?=EC=9D=B4=20=EC=A7=80=EC=9B=8C=EC=A7=80=EB=A9=B4=20=EB=81=9D?= =?UTF-8?q?=EC=9D=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 되돌렸던 6f4e055 를 그대로 되살린다. out/s/ 에는 payload 가 없는 사이트(목업)가 있고, 그건 재굽기 대상이 아니라서 자산이 한 번 지워지면 영영 복구되지 않는다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018xTWrJ6Mrr6HhEN6hZEER4 --- AGENTS.md | 22 +++- docs/DEPLOY.md | 24 ++-- docs/DEVLOG.md | 88 +++++++++++++ solution/site/scripts/prerender.ts | 157 ++++++++++++++++++++---- solution/site/src/seo/directory.test.ts | 46 +++++++ solution/site/src/seo/directory.ts | 32 +++++ 6 files changed, 337 insertions(+), 32 deletions(-) create mode 100644 solution/site/src/seo/directory.test.ts diff --git a/AGENTS.md b/AGENTS.md index 5652113..88bda4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,13 +28,27 @@ - **번들 파일명은 콘텐츠 해시다.** HTML 은 `/assets/index-DvNTmLhy.css` 를 **루트 절대경로**로 가리킨다. 경로는 프리렌더가 `dist/client/.vite/manifest.json` 에서 읽어 박는다 (`prerender.ts:160`). 렌더러 CSS 를 고치면 이름이 바뀐다. -- **로컬 `out/assets` 는 빌드마다 통째로 갈린다** (`prerender.ts:573` `rmSync`). 옛 해시 파일이 - 사라지므로 새 번들로 일부 사이트만 구우면 나머지는 CSS 가 404 다. - → 프리렌더 기동 시 전체 재굽기가 이 구멍을 메운다. +- **옛 해시 자산은 30일 남는다** (`prerender.ts` `ASSET_RETENTION_DAYS`). 예전에는 빌드마다 + `out/assets` 를 통째로 갈아서, 새 번들로 일부만 구우면 나머지 사이트가 CSS 404 였다. + 지금은 남긴다 — 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌리므로, 그 사이 자산이 사라지면 + 스타일 없는 페이지를 렌더한 것으로 기록된다. 보관 근거는 `out/assets/.builds.json` 대장이고 + 파일 mtime 이 아니다. + → 재굽기는 여전히 필요하지만 **급하지 않다**. 디자인이 반영 안 될 뿐, 깨지지는 않는다. +- **★ 대장(`out/assets/.builds.json`)에 없는 자산은 지우지 않는다** — "지금 처음 본 것" 으로 + 치고 보관 기간을 새로 준다(`pruneAssets`). **이 규칙을 깨면 운영 사이트가 즉시 끊긴다.** + 실제로 그랬다(2026-09-07): 대장은 이 기능과 함께 생겼으므로 **배포 직후 첫 실행에는 대장이 + 없고**, 그때 디스크에 있던 기존 자산이 전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다. + 옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다. + → 자산을 지우는 코드를 손볼 때는 **"기록이 없다"와 "만료됐다"를 절대 같이 묶지 않는다.** + → 이미 끊겼다면 복구는 `docker compose restart solution-prerender` (기동하며 전체 재굽기). +- **★ 사이트를 굽는 컨테이너는 `solution-prerender` 다.** `solution-frontend` 는 **개발용**이라 + 운영에서는 아예 뜨지 않는다(`docker-compose.yml` `profiles: ["dev"]`). 이름이 비슷해서 + `restart solution-frontend` 를 치면 **아무 일도 안 일어나는데 명령은 성공한다** — + 재굽기를 했다고 믿고 넘어가게 된다. 실제로 그렇게 복구가 한 번 헛돌았다(2026-09-07). - **★ 프론트(`solution/site`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.** `azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 — **렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.** - → `docker compose restart solution-frontend` 후 `python scripts/republish_all.py` + → `docker compose restart solution-prerender` 후 `python scripts/republish_all.py` - **발행 호스트는 두 곳에 있고 같아야 한다.** 백엔드 `SITE_PUBLIC_HOST`(기본 `web4ai.o2osolution.ai`, `site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부 이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더 diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 2490f3b..9c180f5 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -66,18 +66,28 @@ site-out/ ``` 프론트 수정 → 새 번들(새 해시) - 로컬 out/assets : 통째로 교체 (옛 해시 삭제) ← 재굽기 안 한 사이트는 CSS 404 - Azure : 새 해시 추가, 옛 해시 유지 ← 안 깨지지만 옛 디자인 그대로 박제 + 로컬 out/assets : 새 해시 추가, 옛 해시 30일 보관 ← 안 깨진다. 옛 디자인으로 뜰 뿐 + Azure : 새 해시 추가, 옛 해시 유지 ← 같다 ``` -프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 그래서 로컬 out/ 은 재시작만 -하면 정합이 맞는다. 하지만 Azure 는 발행 잡이 도는 사이트 하나씩만 올린다 — 그 짝을 맞추는 게 -`solution/backend/scripts/republish_all.py` 다. +**2026-09-07 이전에는 로컬 `out/assets` 를 통째로 갈았다.** 그래서 재굽기 전까지 나머지 사이트가 +CSS 404 였다 — 하필 크롤러가 그 순간 렌더하면 스타일 없는 페이지를 본 것으로 기록된다. +지금은 `ASSET_RETENTION_DAYS`(30일) 동안 옛 해시를 남긴다. 보관 근거는 `out/assets/.builds.json` +대장이다(파일 mtime 이 아니다 — 복사·동기화가 시각을 갈아 버린다). + +**그래서 재굽기는 여전히 필요하지만 급하지는 않다.** 안 하면 그 사이트만 옛 디자인으로 뜬다. +프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 하지만 Azure 는 발행 잡이 도는 +사이트 하나씩만 올린다 — 그 짝을 맞추는 게 `solution/backend/scripts/republish_all.py` 다. + +⚠️ 남은 것: `azure_static._upload_shared` 는 매 발행마다 `assets/` **전체**를 다시 올린다. +옛 해시를 남기기 시작했으므로 보관 기간만큼 업로드량이 는다. Azure 를 켤 때는 이미 있는 +블롭(해시 파일이라 이름이 같으면 내용도 같다)을 건너뛰도록 먼저 고친다. + **규칙: `solution/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.** ```bash -docker compose restart solution-frontend # 기동하며 전체 재굽기 +docker compose restart solution-prerender # 기동하며 전체 재굽기 docker compose logs -f solution-frontend # "[watch] 기동" 배치가 끝날 때까지 대기 docker compose exec solution-worker python scripts/republish_all.py ``` @@ -215,5 +225,5 @@ docker compose exec solution-worker python scripts/republish_all.py ## 5. 되돌리기 `out/` 은 재생성물이라 백업이 필요 없다. 문제가 생기면 -`docker compose restart solution-frontend` → 전체 재굽기 → `republish_all.py`. +`docker compose restart solution-prerender` → 전체 재굽기 → `republish_all.py`. 지켜야 할 건 **DB 와 `out/payloads/`** 뿐이다. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 9541b9e..2c42df4 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,94 @@ --- +## 2026-09-07 — (사고) 자산 보관 첫 배포에 운영 사이트 CSS 가 끊겼다 + +**무슨 일** +바로 아래 항목(옛 해시 자산 30일 보관)을 배포하자 **기존 사이트의 CSS·JS 가 전부 404** 가 됐다. +옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다. + +**왜** +`pruneAssets` 가 "대장(`.builds.json`)에 없는 파일" 을 만료로 보고 지웠다. 그런데 **대장은 이 +기능과 함께 처음 생긴다** — 배포 직후 첫 실행에는 대장이 없으므로, 디스크에 있던 기존 자산이 +전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다. 아직 다시 굽지 않은 사이트는 그 순간 죽는다. + +**놓친 것** — 검증을 `out/` 을 비운 상태에서만 돌렸다. 재현해야 했던 건 빈 디렉토리가 아니라 +**"옛 자산은 있는데 대장은 없는"** 상태, 즉 실제 배포 직전의 서버 모습이었다. + +**고친 것** (`scripts/prerender.ts` `pruneAssets`) +- 대장에 없는 파일은 지우지 않고 **"지금 처음 본 것" 으로 입양해** 보관 기간을 새로 준다 +- 규칙으로 굳혀 둔다: **"기록이 없다" 와 "만료됐다" 를 같이 묶지 않는다**(AGENTS.md 함정 목록) + +**복구** — `docker compose restart solution-prerender` (기동하며 전체 재굽기 → HTML 이 새 해시를 +가리킨다). 자산을 되살리는 게 아니라 HTML 을 새로 굽는 쪽이 빠르다. + +**검증** — 배포 직전 상태를 재현: `out/assets` 에 옛 해시 파일만 두고 대장 없이 첫 실행 → +옛 파일 2개가 그대로 남고 대장에 입양 항목으로 들어간다. 재실행해도 대장이 늘지 않는다. + +--- + +## 2026-09-07 — 옛 해시 자산을 30일 남긴다 — 배포와 재굽기를 뗀다 + +**왜** +`writeSharedAssets` 가 빌드마다 `out/assets` 를 통째로 지우고 다시 깔았다. HTML 은 자산 경로를 +파일명 해시까지 박아 굽기 때문에, 렌더러를 배포하는 순간 **아직 다시 굽지 않은 사이트는 전부 +CSS·JS 404** 였다. 구멍을 "기동 시 전체 재굽기" 와 "배포하면 반드시 전체 재업로드" 라는 **규칙** +으로 막고 있었다 — 규칙으로 막는다는 건 구조가 못 막는다는 뜻이다. + +진짜 위험은 방문자가 아니라 크롤러다. 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌린다. +그 사이에 자산이 사라지면 스타일도 스크립트도 없는 페이지를 렌더한 것으로 기록한다 — +하필 지금이 신규 도메인이 평가받는 시기다. 유예 창이 필요하다는 건 업계 통념이고 +(Vercel 은 검색봇에 한해 스큐 보호 창을 60일로 늘린다), 우리 창은 0초였다. + +**바꾼 것** (`scripts/prerender.ts`) +- `assets/` 를 통째로 지우지 않는다. 권한 때문에 지웠던 것인데 `copyDirectoryFiles` 가 + **파일마다** 먼저 `rmSync` 하므로 그 문제는 그대로 해결된다 +- `ASSET_RETENTION_DAYS`(30일) · `ASSET_MIN_BUILDS`(2) — 기간이 지나도 직전 빌드는 남는다 +- `out/assets/.builds.json` 대장: 어떤 빌드가 어떤 파일을 깔았는지. **mtime 으로 나이를 재지 + 않는다** — 복사·동기화가 시각을 갈아 버리면 옛 파일이 영원히 젊어지거나 산 파일이 지워진다. + 발행마다 이 함수가 도므로, 번들이 그대로면 줄을 늘리지 않고 맨 앞 줄의 시각만 갱신한다 +- 점(.)으로 시작해 `azure_static` 의 dotfile 필터에 걸러진다 — 대장은 업로드되지 않는다 + +**얻은 것** — 프론트 배포와 전체 재굽기가 **분리된다.** 재굽기를 안 하면 그 사이트만 옛 +디자인으로 뜬다(예전엔 깨졌다). AGENTS.md 의 ★규칙은 남지만 이유가 "안 하면 죽는다" 에서 +"안 하면 반영이 안 된다" 로 내려온다. + +**남은 것** — `azure_static._upload_shared` 가 매 발행마다 `assets/` 전체를 올린다. 보관 기간만큼 +업로드량이 는다. Azure 는 지금 꺼져 있으므로(DEPLOY.md) 켤 때 이미 있는 블롭을 건너뛰도록 고친다. + +**검증** — 실제로 세 번 구워 확인: 번들 해시가 바뀌어도 옛 파일 3개가 그대로 남고, 같은 번들로 +다시 구우면 대장이 늘지 않으며(2줄 유지), 대장의 마지막 줄을 60일 전으로 돌리자 그 빌드의 +파일 3개만 정리됐다. `tsc·eslint` 통과, `vitest` 22 passed. + +--- + +## 2026-09-07 — 사이트맵 lastmod 를 파일 mtime 에서 뗐다 + +**왜** +`lastmod` 를 구운 `index.html` 의 **파일 mtime** 에서 읽고 있었다. 그런데 렌더러를 배포하면 +번들 해시가 바뀌어 **내용이 한 글자도 안 바뀐 사이트까지 전부 다시 구워진다** — mtime 은 +그때마다 오늘이 되고, 사이트맵은 "전 사이트가 오늘 갱신됨" 을 통보한다. + +구글은 lastmod 를 페이지의 실제 수정과 대조해 맞을 때만 쓰고, 어긋나면 **그 필드를 아예 +무시한다**(Search Central: "the date and time of the last significant update" · +"consistently and verifiably accurate"). 즉 이 오염은 지금 당장 뭘 깨뜨리는 게 아니라, +**사장님이 진짜로 내용을 고쳐 재발행한 날의 신호를 미리 죽여 두는** 종류다. 배포할 때마다 +신뢰를 태우고 있었고, 사이트가 100개를 넘기면 되돌리는 데 시간이 걸린다. + +**바꾼 것** +- `seo/directory.ts`: `readBakedTitle` · `readBakedLastmod` — 구운 HTML 에서 목록·사이트맵 + 값을 꺼낸다. lastmod 는 페이지가 head 에 선언한 `dateModified`(= `payload.site.updatedAt`) + **그 값 그대로**다. 구글이 대조하는 값과 글자 그대로 같아 어긋날 수가 없다 +- `scripts/prerender.ts`: `readTitle` 을 위로 옮기고 사이트맵 항목에서 mtime 제거. 파일을 + 한 번만 읽어 제목과 lastmod 를 같이 꺼낸다. mtime 은 `dateModified` 메타가 없던 시절의 + 산출물에만 남는 폴백이다 — 그 사이트를 한 번 다시 구우면 제 값이 들어온다 +- `seo/directory.test.ts`: head.ts 의 메타와 파서의 **커플링을 고정**한다. 태그 모양이 바뀌면 + 파서가 조용히 undefined 를 내고 mtime 으로 되돌아간다 — 빌드도 화면도 멀쩡한 회귀라서 붙였다 + +**검증** — `tsc·eslint` 통과, `vitest` 22 passed (신규 5건). + +--- + ## 2026-09-03 — 레포·발행 호스트 교체 — `o2o-site-AEO` / `web4ai.o2osolution.ai` **왜** diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index e8ab11d..495dc79 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -22,6 +22,8 @@ import {render} from '@/entry-server'; import { collectJsonLd, homeMeta, + readBakedLastmod, + readBakedTitle, renderHead, renderLlmsTxt, renderRootLlmsTxt, @@ -477,18 +479,134 @@ function writeReport(payloadFile: string, report: RenderReport) { renameSync(tmp, target); } +/** + * 옛 자산 보관 기간. + * + * ★ 왜 지우지 않고 남기나 — HTML 은 자산 경로를 **파일명 해시까지 박아** 굽는다 + * (`/assets/index-DvNTmLhy.css`). 렌더러를 배포하면 이름이 바뀌는데, 그 순간 옛 파일을 + * 지우면 아직 다시 굽지 않은 사이트는 CSS·JS 가 **404** 다. 예전 구현이 그랬다. + * + * ★ 더 나쁜 건 크롤러다. 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌린다. 그 사이에 + * 자산이 사라지면 스타일도 스크립트도 없는 페이지를 렌더한 것으로 기록한다 — 하필 + * 신규 도메인이 평가받는 시기에 그렇게 된다. 유예 창이 필요하다는 게 업계 통념이고 + * (Vercel 은 검색봇에 한해 스큐 보호 창을 60일로 늘린다), 우리 창은 0초였다. + * + * 한 벌이 400KB 안팎이라 한 달치를 남겨도 10MB 남짓이다 — 싸게 사는 안전이다. + */ +const ASSET_RETENTION_DAYS = 30; +/** 하루에 여러 번 배포해도 직전 빌드는 반드시 남는다(보관 기간과 무관). */ +const ASSET_MIN_BUILDS = 2; +/** + * 어떤 빌드가 어떤 파일을 깔았는지. **보관 기간의 근거는 이 파일이다.** + * + * ★ 파일 mtime 으로 나이를 재지 않는다 — 복사·동기화가 시각을 갈아 버리면 옛 파일이 + * 영원히 젊어지거나 산 파일이 지워진다. 점(.)으로 시작해 업로드에서 빠진다 + * (azure_static 이 dotfile 을 거른다). + */ +const ASSET_LEDGER = '.builds.json'; + +/** 자산 대장 한 줄 — 한 번의 번들 빌드가 깐 파일 목록. */ +interface AssetBuild { + /** 이 번들을 마지막으로 깐 시각(ISO). 같은 번들로 다시 구우면 갱신된다. */ + at: string; + /** assets/ 기준 상대 경로. */ + files: string[]; +} + +/** 디렉토리 안의 파일을 상대 경로로 편다(하위 디렉토리 포함). */ +function listRelativeFiles(dir: string, prefix = ''): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, {withFileTypes: true})) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) found.push(...listRelativeFiles(join(dir, entry.name), rel)); + else if (entry.isFile()) found.push(rel); + } + return found; +} + +function readAssetLedger(assetsDir: string): AssetBuild[] { + try { + const parsed = JSON.parse(readFileSync(join(assetsDir, ASSET_LEDGER), 'utf-8')) as { + builds?: AssetBuild[]; + }; + return Array.isArray(parsed.builds) ? parsed.builds : []; + } catch { + // 없거나 깨졌으면 빈 대장으로 시작한다. 디스크에 있던 파일은 pruneAssets 가 + // "처음 본 것" 으로 입양하므로 지워지지 않는다 — 그 ★ 주석이 이 실패의 근거다. + return []; + } +} + +/** + * 보관 기간이 지난 옛 해시 파일만 지운다. + * + * ★ 발행할 때마다 이 함수가 돈다(사이트 하나만 구울 때도). 번들이 그대로면 대장에 줄이 + * 늘지 않고 맨 앞 줄의 시각만 갱신된다 — 안 그러면 발행 횟수만큼 대장이 자란다. + */ +function pruneAssets(assetsDir: string, current: string[]) { + const signature = (files: string[]) => [...files].sort().join('\n'); + const now = new Date().toISOString(); + const previous = readAssetLedger(assetsDir); + const head: AssetBuild = {at: now, files: current}; + const builds = + previous[0] && signature(previous[0].files) === signature(current) + ? [head, ...previous.slice(1)] + : [head, ...previous]; + + /** + * ★ 대장에 없는 파일은 **지우지 않는다.** 언제 깔렸는지 모를 뿐이므로 지금 처음 본 것으로 + * 치고 보관 기간을 새로 준다. + * + * 이 줄이 없어서 실제로 운영 사이트가 끊겼다(2026-09-07). 대장은 이 기능과 함께 생겼으니 + * **배포 직후 첫 실행에는 대장이 없다** — 그때 디스크에 있던 기존 자산이 전부 "대장에 없음" + * 으로 분류돼 한꺼번에 삭제됐고, 아직 다시 굽지 않은 사이트의 CSS 가 통째로 404 가 됐다. + * 옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다. + */ + const recorded = new Set(builds.flatMap((build) => build.files)); + const adopted = listRelativeFiles(assetsDir).filter( + (file) => file !== ASSET_LEDGER && !recorded.has(file), + ); + if (adopted.length > 0) { + builds.push({at: now, files: adopted}); + console.log(` ✓ 대장에 없던 자산 ${adopted.length}개를 보관 대상으로 넣는다`); + } + + const cutoff = Date.now() - ASSET_RETENTION_DAYS * 24 * 60 * 60 * 1000; + const kept = builds.filter( + (build, index) => index < ASSET_MIN_BUILDS || Date.parse(build.at) >= cutoff, + ); + const alive = new Set(kept.flatMap((build) => build.files)); + + let removed = 0; + for (const file of listRelativeFiles(assetsDir)) { + if (file === ASSET_LEDGER || alive.has(file)) continue; + rmSync(join(assetsDir, file), {force: true}); + removed += 1; + } + + writeFileSync( + join(assetsDir, ASSET_LEDGER), + JSON.stringify({schemaVersion: 1, builds: kept}, null, 2), + 'utf-8', + ); + if (removed > 0) { + console.log(` ✓ 보관 기간(${ASSET_RETENTION_DAYS}일)이 지난 자산 ${removed}개 정리`); + } +} + /** * 클라이언트 번들과 public/ 을 대상 디렉토리에 깐다. * - * Docker bind mount 에서 이전 빌드가 남긴 파일 권한이 호스트와 어긋날 수 있어 - * assets/ 는 통째로 교체한다(낡은 해시 파일과 권한을 함께 제거). + * ★ assets/ 를 통째로 지우고 다시 깔지 않는다(예전 구현). 옛 해시 파일은 보관 기간까지 + * 남겨야 한다 — 근거는 ASSET_RETENTION_DAYS 주석. 권한 때문에 통째로 지웠던 것인데, + * copyDirectoryFiles 가 **파일마다** 먼저 rmSync 하므로 그 문제는 그대로 해결된다. */ function writeSharedAssets(destRoot: string) { const assetsSrc = join(CLIENT_DIR, 'assets'); if (existsSync(assetsSrc)) { const assetsDest = join(destRoot, 'assets'); - rmSync(assetsDest, {recursive: true, force: true}); copyDirectoryFiles(assetsSrc, assetsDest); + pruneAssets(assetsDest, listRelativeFiles(assetsSrc)); } const publicDir = join(SITE_ROOT, 'public'); if (existsSync(publicDir)) { @@ -518,18 +636,21 @@ function writeRootMachineFiles(outRoot: string, origin: string) { .map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')})) // index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. 사이트맵에 넣지 않는다. .filter((entry) => existsSync(entry.file)) - .map((entry) => ({ - // ★ 끝 슬래시를 붙이지 않는다. 페이지의 canonical 은 `/s/<slug>` 다(shared/lib/slug.ts - // publishUrl). 사이트맵이 `/s/<slug>/` 로 어긋나 있던 동안 서치콘솔은 제출한 URL 을 - // 전부 "대체 페이지(적절한 표준 태그가 있음)" 로 분류했다 — 색인은 되는데 제출분은 - // 0건으로 보이는, 눈으로 원인을 못 찾는 종류다. - loc: joinUrl(origin, SITE_DIR, entry.slug), - // 제목은 구운 HTML 에서 읽는다. payload 에서 가져오면 이번 실행분만 이름이 있고 - // 나머지는 슬러그로 떨어진다 — 발행은 바뀐 사이트 하나만 굽기 때문이다. - title: readTitle(entry.file) || entry.slug, - // 페이지는 그 사이트를 구울 때마다 다시 쓰인다 — 파일 mtime 이 곧 마지막 발행 시각이다. - lastmod: statSync(entry.file).mtime.toISOString(), - })) + .map((entry) => { + // 제목과 lastmod 가 같은 HTML 에서 나온다 — 파일은 한 번만 읽는다. + const html = readFileSync(entry.file, 'utf-8'); + return { + // ★ 끝 슬래시를 붙이지 않는다. 페이지의 canonical 은 `/s/<slug>` 다(shared/lib/slug.ts + // publishUrl). 사이트맵이 `/s/<slug>/` 로 어긋나 있던 동안 서치콘솔은 제출한 URL 을 + // 전부 "대체 페이지(적절한 표준 태그가 있음)" 로 분류했다 — 색인은 되는데 제출분은 + // 0건으로 보이는, 눈으로 원인을 못 찾는 종류다. + loc: joinUrl(origin, SITE_DIR, entry.slug), + title: readBakedTitle(html) || entry.slug, + // ★ mtime 으로 떨어지는 건 dateModified 메타가 없던 시절의 산출물뿐이다. + // 그 사이트를 한 번 다시 구우면 제 값이 들어온다(readBakedLastmod 주석 참조). + lastmod: readBakedLastmod(html) ?? statSync(entry.file).mtime.toISOString(), + }; + }) .sort((a, b) => a.loc.localeCompare(b.loc)); // ★ `/s/` 목록 페이지. 크롤러가 발행본에 닿는 두 번째 경로다 — @@ -549,12 +670,6 @@ function writeRootMachineFiles(outRoot: string, origin: string) { writeIndexNowKey(outRoot); } -/** 구운 index.html 에서 <title> 만 꺼낸다. 파서를 붙일 값어치가 없는 한 줄짜리 일이다. */ -function readTitle(file: string): string { - const match = /<title>([^<]*)<\/title>/.exec(readFileSync(file, 'utf-8')); - return match ? match[1].trim() : ''; -} - /** * IndexNow 키 파일 — `https://<host>/<key>.txt` 에 키 문자열만 들어 있다. * diff --git a/solution/site/src/seo/directory.test.ts b/solution/site/src/seo/directory.test.ts new file mode 100644 index 0000000..958cedf --- /dev/null +++ b/solution/site/src/seo/directory.test.ts @@ -0,0 +1,46 @@ +/** + * 구운 HTML → 사이트맵·목록 값. + * + * ★ 이 테스트가 지키는 것은 **커플링 하나**다. 사이트맵의 lastmod 는 페이지가 head 에 + * 선언한 dateModified 와 같은 값이어야 한다 — 구글이 lastmod 를 신뢰하는 조건이 + * "페이지의 실제 수정과 대조해 맞을 것" 이기 때문이다. head.ts 가 태그 모양을 바꾸면 + * readBakedLastmod 는 조용히 undefined 를 내고 사이트맵은 mtime 으로 되돌아간다 — + * 빌드는 통과하고 화면도 멀쩡한, 눈으로 못 찾는 종류의 회귀다. 그래서 붙인다. + */ +import {describe, expect, it} from 'vitest'; + +import {MOONLIGHT_STAY_PAYLOAD} from '../fixtures/moonlight-stay'; +import {readBakedLastmod, readBakedTitle} from './directory'; +import {renderHead} from './head'; +import {homeMeta} from './meta'; + +/** 실제 프리렌더와 같은 경로로 head 를 굽는다(prerender.ts prerenderSite). */ +function bakedHead(): string { + const payload = MOONLIGHT_STAY_PAYLOAD; + return renderHead({payload, meta: homeMeta(payload)}); +} + +describe('readBakedLastmod', () => { + it('head 가 선언한 dateModified 를 그대로 돌려준다', () => { + expect(readBakedLastmod(bakedHead())).toBe(MOONLIGHT_STAY_PAYLOAD.site.updatedAt); + }); + + it('메타가 없으면 undefined — 호출부가 mtime 으로 떨어진다', () => { + expect(readBakedLastmod('<html><head><title>x')).toBeUndefined(); + }); + + it('날짜로 못 읽는 값은 버린다 — 틀린 lastmod 는 없는 것보다 나쁘다', () => { + expect(readBakedLastmod('')).toBeUndefined(); + expect(readBakedLastmod('')).toBeUndefined(); + }); +}); + +describe('readBakedTitle', () => { + it('구운 HTML 의 title 을 꺼낸다', () => { + expect(readBakedTitle(bakedHead())).toBe(homeMeta(MOONLIGHT_STAY_PAYLOAD).title); + }); + + it('title 이 없으면 빈 문자열 — 호출부가 슬러그로 떨어진다', () => { + expect(readBakedTitle('')).toBe(''); + }); +}); diff --git a/solution/site/src/seo/directory.ts b/solution/site/src/seo/directory.ts index 6c87b9d..1dde43c 100644 --- a/solution/site/src/seo/directory.ts +++ b/solution/site/src/seo/directory.ts @@ -9,6 +9,38 @@ export interface DirectoryEntry { lastmod?: string; } +/** + * 구운 `index.html` 에서 `` 만 꺼낸다. 파서를 붙일 값어치가 없는 한 줄짜리 일이다. + * + * ★ 왜 payload 가 아니라 구운 HTML 을 읽나 — 발행은 **바뀐 사이트 하나만** 굽는다. + * 이번 실행분으로만 목록을 만들면 나머지 사이트가 목록에서 사라진다. + */ +export function readBakedTitle(html: string): string { + const match = /<title>([^<]*)<\/title>/.exec(html); + return match ? match[1].trim() : ''; +} + +/** + * 사이트맵 `lastmod` — **페이지가 스스로 선언한 `dateModified` 를 그대로** 쓴다. + * + * ★ 파일 mtime 을 쓰면 안 된다(예전 구현). 렌더러를 배포하면 번들 해시가 바뀌어 내용이 + * 같은 사이트까지 전부 다시 구워진다 — mtime 은 그때마다 오늘이 되고, 사이트맵은 + * **"전 사이트가 오늘 갱신됨"** 을 통보한다. 구글은 lastmod 를 페이지의 실제 수정과 + * 대조해 맞을 때만 쓰고 어긋나면 그 필드를 **아예 무시한다**(Search Central: "the date and + * time of the last significant update" · "consistently and verifiably accurate"). + * 즉 이 오염은 사장님이 **진짜로** 내용을 고쳐 재발행한 날의 신호까지 같이 죽인다. + * + * ★ head.ts 의 `dateModified` 메타와 **같은 값**을 읽는다 — 구글이 대조하는 그 값이라 + * 사이트맵과 페이지가 어긋날 수 없다. head.ts 가 이 태그를 바꾸면 여기도 같이 고친다 + * (directory.test.ts 가 그 커플링을 고정해 둔다). + */ +export function readBakedLastmod(html: string): string | undefined { + const match = /<meta name="dateModified" content="([^"]*)"/.exec(html); + const value = match?.[1].trim(); + // 값이 깨졌으면 넣지 않는다 — 틀린 lastmod 는 없는 것보다 나쁘다(위 ★ 참조). + return value && !Number.isNaN(Date.parse(value)) ? value : undefined; +} + /** * `/s/` 발행 사이트 목록 페이지. * From 6df125d84088707ef1dc6688201885f9857d2fca Mon Sep 17 00:00:00 2001 From: Mina Choi <mnchoi@o2o.kr> Date: Mon, 7 Sep 2026 14:06:09 +0900 Subject: [PATCH 14/18] =?UTF-8?q?[fix]=20solution/site:=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=EB=B3=B8=EC=9D=B4=20=EC=B0=B8=EC=A1=B0=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=9E=90=EC=82=B0=EC=9D=80=20=EA=B8=B0=EA=B0=84?= =?UTF-8?q?=EA=B3=BC=20=EB=AC=B4=EA=B4=80=ED=95=98=EA=B2=8C=20=EB=82=A8?= =?UTF-8?q?=EA=B8=B4=EB=8B=A4=20=E2=80=94=20=EB=AA=A9=EC=97=85=EC=9D=B4=20?= =?UTF-8?q?=EC=A3=BD=EC=97=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /s/stay · /s/stay2 · /s/stay3 의 CSS·JS·이미지가 전부 404 가 됐고 재굽기로 살아나지 않았다. out/s/ 에 디렉토리가 8개인데 payload 는 4개뿐이다. 나머지는 손으로 넣은 목업이고, 프리렌더는 payload 를 받은 사이트만 굽는다 — 목업은 재굽기 대상이 아니라서 자산이 한 번 지워지면 영영 복구되지 않는다. 문서 어디에도 목업 얘기가 없어서(grep 0건) 그 존재를 모르고 자산 삭제 코드를 건드렸다. 보관 기간으로는 못 막는다. 기간이 지나면 같은 사고가 난다. 참조가 살아 있으면 남겨야 한다. - referencedAssets(): 굽기 전에 out/s/**/index.html 을 훑어 /assets/… 참조를 모은다 - pruneAssets: 그 목록은 절대 지우지 않는다 — 보관 기간보다 우선한다 - AGENTS.md: 목업의 존재와 "자산 삭제 코드는 참조를 먼저 뺀다" 를 함정 맨 위에 ★★로 - docs/DEVLOG.md: 사고 기록과 복구 경로 검증: payload 없는 목업을 재현해 재굽기 → 참조 3개 유지. 대장을 60일 전으로 돌려 만료를 강제해도 유지. tsc·eslint 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018xTWrJ6Mrr6HhEN6hZEER4 --- AGENTS.md | 9 +++++ docs/DEVLOG.md | 26 ++++++++++++++ solution/site/scripts/prerender.ts | 57 +++++++++++++++++++++++++----- 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 88bda4f..0c725e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,15 @@ 밟으면 **조용히 틀린다** — 빌드는 성공하고 화면도 뜨는데 결과가 잘못된 종류다. +- **★★ `out/s/` 에는 payload 가 없는 사이트가 있다 — 목업(`stay` · `stay2` · `stay3` · `*.old`).** + 프리렌더는 **payload 를 받은 사이트만** 굽는다. 목업은 손으로 넣은 것이라 재굽기 대상이 + 아니고, **자산이 한 번 지워지면 영영 복구되지 않는다** — 재굽기를 몇 번 돌려도 안 살아나고 + 사람이 파일을 되돌려 넣어야 한다. 실측(2026-09-07): 번들 해시가 바뀌자 목업 3개의 CSS·JS· + 이미지가 전부 404 가 됐고, 그 파일들은 `stay-mockup` 워크트리에서 손으로 꺼내 복구했다. + → **`out/assets` 에서 파일을 지우는 코드는 `out/s/**` 의 HTML 이 참조하는 것을 먼저 뺀다** + (`prerender.ts` `referencedAssets`). 보관 기간으로는 못 막는다 — 기간이 지나면 같은 일이 난다. + → 목업을 다루는 작업은 `out/s/` 를 먼저 열어 **payload 가 없는 디렉토리가 무엇인지** 본다. + - **번들 파일명은 콘텐츠 해시다.** HTML 은 `/assets/index-DvNTmLhy.css` 를 **루트 절대경로**로 가리킨다. 경로는 프리렌더가 `dist/client/.vite/manifest.json` 에서 읽어 박는다 (`prerender.ts:160`). 렌더러 CSS 를 고치면 이름이 바뀐다. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 2c42df4..bdfbe3b 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,32 @@ --- +## 2026-09-07 — (사고 2) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다 + +**무슨 일** +`/s/stay` · `/s/stay2` · `/s/stay3` 의 CSS·JS·이미지가 전부 404 가 됐다. 재굽기를 돌려도 +살아나지 않았다. + +**왜** +`out/s/` 에 디렉토리가 8개인데 payload 는 4개뿐이다. 나머지는 **손으로 넣은 목업**이고, +프리렌더는 payload 를 받은 사이트만 굽는다 — 목업은 **재굽기 대상이 아니다.** 그래서 번들 +해시가 바뀌어 옛 자산이 지워지는 순간 영영 복구 불가가 된다. 문서 어디에도 목업 얘기가 +한 줄도 없어서(2026-09-07 grep 0건) 이 존재를 모르고 자산 삭제 코드를 건드렸다. + +**고친 것** (`scripts/prerender.ts`) +- `referencedAssets()` — 굽기 **전에** `out/s/**/index.html` 을 훑어 `/assets/…` 참조를 모은다 +- `pruneAssets` 가 그 목록을 절대 지우지 않는다. **보관 기간보다 우선한다** — + 기간으로 막으면 30일 뒤에 똑같은 사고가 난다 +- AGENTS.md 함정 목록 맨 위에 ★★ 로 박았다. 목업의 존재 자체가 문서에 없던 게 근본 원인이다 + +**복구** — 지워진 파일은 `stay-mockup` 워크트리(`solution/site/out/assets`)에 남아 있어서 +서버 볼륨에 손으로 되돌려 넣었다. `docker cp` → `out/assets`. + +**검증** — 목업 상황 재현: payload 없는 `out/s/mock/index.html` 이 옛 해시를 가리키게 두고 +재굽기 → 참조 3개가 남는다. 대장을 60일 전으로 돌려 만료를 강제해도 그대로 남는다. + +--- + ## 2026-09-07 — (사고) 자산 보관 첫 배포에 운영 사이트 CSS 가 끊겼다 **무슨 일** diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index 495dc79..4cd7181 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -285,7 +285,12 @@ function assetPlan(payload: SitePayload, outRoot: string, siteDir: string) { }; } -function prerenderSite(input: SitePayload, outRoot: string, assets: ReturnType<typeof readAssets>) { +function prerenderSite( + input: SitePayload, + outRoot: string, + assets: ReturnType<typeof readAssets>, + referenced: Set<string>, +) { // ★ 여기서 한 번 깎고, 그 뒤로는 깎인 payload 만 쓴다 — // 서버 렌더 · JSON-LD · llms.txt · HTML 에 심는 블롭이 전부 같은 객체를 본다. // (블롭만 원본으로 두면 미검증 fact 가 HTML 소스로 새고, AI 크롤러는 그걸 읽는다.) @@ -370,7 +375,7 @@ function prerenderSite(input: SitePayload, outRoot: string, assets: ReturnType<t // 하이드레이션용 번들. 공용 호스트면 out/ 루트 한 벌을 공유하므로 여기서는 아무것도 안 한다 // (main 이 사이트를 굽기 전에 한 번 깔아 둔다). 커스텀 도메인일 때만 사이트 안에 복사한다. if (!plan.shared) { - writeSharedAssets(plan.dir); + writeSharedAssets(plan.dir, referenced); } // 이전 구현이 사이트마다 복사해 둔 자산이 남아 있으면 지운다 — 공용으로 바뀐 뒤에는 @@ -524,6 +529,37 @@ function listRelativeFiles(dir: string, prefix = ''): string[] { return found; } +/** + * 발행본이 **지금 실제로 참조하고 있는** 자산. 여기 들어오면 절대 지우지 않는다. + * + * ★ 왜 보관 기간만으로는 부족한가 — `out/s/` 에는 **payload 가 없는 사이트**가 있다(목업). + * 그건 재굽기 대상이 아니다. 프리렌더는 payload 를 받아 그 사이트만 굽고, payload 가 없는 + * 디렉토리는 쳐다보지도 않는다 — 그래서 자산이 한 번 지워지면 **영영 복구되지 않는다.** + * 재굽기를 몇 번을 돌려도 살아나지 않고, 사람이 파일을 손으로 되돌려 넣어야 한다. + * + * 실측(2026-09-07): `/s/stay` · `/s/stay2` · `/s/stay3` 가 번들 해시가 바뀐 순간 + * CSS·JS·이미지 전부 404 가 됐다. 보관 기간(30일)으로는 못 막는다 — 기간이 지나면 + * 똑같은 일이 난다. **참조가 살아 있는 한 남긴다** 가 유일하게 맞는 규칙이다. + * + * ★ 사이트를 굽기 전에 부른다. 그래야 이번에 다시 굽지 않는 사이트의 옛 참조가 잡힌다. + */ +function referencedAssets(outRoot: string): Set<string> { + const sitesDir = join(outRoot, SITE_DIR); + const found = new Set<string>(); + if (!existsSync(sitesDir)) return found; + + for (const entry of readdirSync(sitesDir, {withFileTypes: true})) { + if (!entry.isDirectory()) continue; + const file = join(sitesDir, entry.name, 'index.html'); + if (!existsSync(file)) continue; + // basePath 가 붙어도(`/sites/assets/…`) `/assets/` 뒤만 집으면 파일 경로가 나온다. + for (const match of readFileSync(file, 'utf-8').matchAll(/\/assets\/([^"'\s)\\]+)/g)) { + found.add(decodeURIComponent(match[1])); + } + } + return found; +} + function readAssetLedger(assetsDir: string): AssetBuild[] { try { const parsed = JSON.parse(readFileSync(join(assetsDir, ASSET_LEDGER), 'utf-8')) as { @@ -543,7 +579,7 @@ function readAssetLedger(assetsDir: string): AssetBuild[] { * ★ 발행할 때마다 이 함수가 돈다(사이트 하나만 구울 때도). 번들이 그대로면 대장에 줄이 * 늘지 않고 맨 앞 줄의 시각만 갱신된다 — 안 그러면 발행 횟수만큼 대장이 자란다. */ -function pruneAssets(assetsDir: string, current: string[]) { +function pruneAssets(assetsDir: string, current: string[], referenced: Set<string>) { const signature = (files: string[]) => [...files].sort().join('\n'); const now = new Date().toISOString(); const previous = readAssetLedger(assetsDir); @@ -579,7 +615,8 @@ function pruneAssets(assetsDir: string, current: string[]) { let removed = 0; for (const file of listRelativeFiles(assetsDir)) { - if (file === ASSET_LEDGER || alive.has(file)) continue; + // ★ 참조가 살아 있으면 기간과 무관하게 남긴다 — referencedAssets 주석 참조. + if (file === ASSET_LEDGER || alive.has(file) || referenced.has(file)) continue; rmSync(join(assetsDir, file), {force: true}); removed += 1; } @@ -601,12 +638,12 @@ function pruneAssets(assetsDir: string, current: string[]) { * 남겨야 한다 — 근거는 ASSET_RETENTION_DAYS 주석. 권한 때문에 통째로 지웠던 것인데, * copyDirectoryFiles 가 **파일마다** 먼저 rmSync 하므로 그 문제는 그대로 해결된다. */ -function writeSharedAssets(destRoot: string) { +function writeSharedAssets(destRoot: string, referenced: Set<string>) { const assetsSrc = join(CLIENT_DIR, 'assets'); if (existsSync(assetsSrc)) { const assetsDest = join(destRoot, 'assets'); copyDirectoryFiles(assetsSrc, assetsDest); - pruneAssets(assetsDest, listRelativeFiles(assetsSrc)); + pruneAssets(assetsDest, listRelativeFiles(assetsSrc), referenced); } const publicDir = join(SITE_ROOT, 'public'); if (existsSync(publicDir)) { @@ -698,8 +735,12 @@ function main() { console.log(`[prerender] 사이트 ${loaded.length}개 → ${args.out}`); + // ★ 굽기 **전에** 참조를 훑는다. 이번에 다시 굽지 않는 사이트(payload 가 없는 목업 포함)가 + // 무엇을 가리키고 있는지는 지금 디스크에 있는 HTML 만 안다. + const referenced = referencedAssets(args.out); + // ★ 공용 자산은 사이트를 굽기 전에 딱 한 번 깐다. 사이트마다 복사하던 걸 여기로 뺐다. - writeSharedAssets(args.out); + writeSharedAssets(args.out, referenced); let failed = 0; /** 루트 기계용 파일을 쓸 오리진. 이 호스트의 사이트는 전부 같은 오리진을 쓴다. */ @@ -743,7 +784,7 @@ function main() { } try { - const result = prerenderSite(entry.payload, args.out, assets); + const result = prerenderSite(entry.payload, args.out, assets, referenced); const payload = result.payload; origin = origin || payload.site.origin; const home = joinUrl(payload.site.origin, payload.site.basePath); From 00e13bca7fedb8045eeb533f56868b75cfbbff0a Mon Sep 17 00:00:00 2001 From: Mina Choi <mnchoi@o2o.kr> Date: Tue, 8 Sep 2026 09:53:19 +0900 Subject: [PATCH 15/18] =?UTF-8?q?[feat]=20solution/site,shared:=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89=EB=B3=B8=EC=9D=84=20/s/stay=20=EB=AA=A9?= =?UTF-8?q?=EC=97=85=EC=97=90=20=EB=A7=9E=EC=B6=98=EB=8B=A4=20=E2=80=94=20?= =?UTF-8?q?=EC=9E=83=EC=96=B4=EB=B2=84=EB=A6=B0=20CSS=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=20=EB=B3=B5=EC=9B=90=20+=20=EC=84=B9=EC=85=98=20=EB=A7=88?= =?UTF-8?q?=ED=81=AC=EC=97=85=20=EC=9D=B4=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목업(/s/stay)이 참조하는 스타일시트가 서버에서 사라져(404) 그 화면을 기준으로 삼을 수 없었다. 브라우저 캐시에 남아 있던 규칙을 꺼내 레포에 되돌리고, 섹션 마크업을 목업 HTML 에서 그대로 옮겼다. Playwright 대조로 섹션 래퍼·제목 16/17, 안쪽 구조 14/17 일치. - index.css: --fs-display~--fs-xs(유동 타이포 7) · --section-space · --color-line/muted · .h2 .h3 .panel .paper .measure .border-line .text-muted .divide-line .slider-viewport/track ★ 이 토큰이 없던 동안 컴포넌트가 var(--fs-display)를 써도 브라우저가 조용히 무시했다 — 히어로 제목이 본문 크기로 나오던 원인 - HeroSection: 가운데 → 좌하단. 사진 위에 글자를 한가운데 얹으면 피사체를 정확히 가린다 - AnswerBlock·EssentialInfo·Units·Gallery·Location·Faq·LocalGuide·Weather·About: 목업 마크업 그대로. embla 를 scroll-snap 으로 바꿔 스크립트 없이도 레일이 밀린다 - FestivalSection(계절 탭) · StorySection(이야기 탭 래퍼) · items/TripMap(OSM 타일 지도) 신설 ★ 지도는 iframe 이 아니라 타일을 직접 깐다 — iframe 은 핀을 하나밖에 못 찍어 "어떤 순서로 도는가" 를 그릴 수 없다 - items: itinerary·event·video kind 추가(파서에 없어 payload 까지 실려 오고도 화면에서 사라지던 것) · Rail 을 slider-viewport/track 으로 · SongsSection 을 턴테이블로 - color.ts: 선 색을 secondary → mix(bg, text, .2). secondary 는 '본문 다음으로 진한 글자색' 이라 선으로 쓰면 표와 카드가 격자무늬처럼 새까맣게 그어진다 - seo/head.ts: --tpl-texture. 없으면 색만 갱지고 면은 매끈해서 인쇄물로 안 보인다 - seo/verify.ts: unitCode·numberOfRooms 를 화면 대조에서 뺀다 ★ unitCode 는 ㎡의 ISO 코드로 priceCurrency 와 같은 성격인데 예외에 없었다. 그래서 room_size 가 있는 사업장은 발행 게이트가 전부 막았다(실측: 가은채 객실 12개). stay 는 객실 2개 + room_size 없음이라 우연히 통과했다 - shared: LocalPlace.imageUrl/distanceMeters · FestivalEntry.imageUrl · ItineraryStop 좌표 · TemplateLook.texture · people/chronicle/postcard imageUrl ★ 좌표·사진은 모델이 만드는 칸이 아니다. 공식 API 로 조회해 채운다 검증: tsc 통과 · Playwright 대조(목업 CSS 주입) 섹션 래퍼·제목 16/17, 안쪽 14/17. 남은 차이는 데이터 한계다 — people 사진은 위키에 원본이 없고(original:false), weather.note 는 정적 페이지에 날씨 문장을 박는 문제라 보류. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KP1ykMnWZFtLFpm2mow1Sw --- solution/shared/src/lib/color.ts | 9 +- solution/shared/src/lib/section-data.ts | 68 +++- solution/shared/src/types/builder.ts | 7 + solution/shared/src/types/site-payload.ts | 10 + solution/site/src/index.css | 109 +++++++ solution/site/src/pages/HomePage.tsx | 16 +- solution/site/src/sections/AboutSection.tsx | 32 +- solution/site/src/sections/AnswerBlock.tsx | 54 +-- solution/site/src/sections/BookingSection.tsx | 9 +- .../src/sections/EssentialInfoSection.tsx | 145 ++++++--- .../site/src/sections/ExhibitionSection.tsx | 9 +- solution/site/src/sections/FaqSection.tsx | 47 +-- .../site/src/sections/FestivalSection.tsx | 162 +++++++++ solution/site/src/sections/GallerySection.tsx | 246 ++++++-------- solution/site/src/sections/HeroSection.tsx | 128 +++++--- solution/site/src/sections/InquirySection.tsx | 9 +- .../site/src/sections/LocalGuideSection.tsx | 308 +++++++++++------- .../site/src/sections/LocationSection.tsx | 211 ++++++------ solution/site/src/sections/RulesSection.tsx | 9 +- solution/site/src/sections/SectionShell.tsx | 68 ++++ solution/site/src/sections/SpaceSection.tsx | 9 +- solution/site/src/sections/StorySection.tsx | 101 ++++++ solution/site/src/sections/UnitsSection.tsx | 235 +++++++------ solution/site/src/sections/WeatherSection.tsx | 80 +++-- solution/site/src/sections/index.ts | 2 + .../src/sections/items/ChronicleSection.tsx | 24 +- .../site/src/sections/items/EventSection.tsx | 54 +++ .../src/sections/items/ItinerarySection.tsx | 210 ++++++++++++ .../site/src/sections/items/PeopleSection.tsx | 44 ++- .../src/sections/items/PostcardSection.tsx | 12 +- .../site/src/sections/items/SongsSection.tsx | 171 +++++++--- solution/site/src/sections/items/TripMap.tsx | 128 ++++++++ .../site/src/sections/items/VideoSection.tsx | 50 +++ solution/site/src/sections/items/common.tsx | 44 ++- solution/site/src/sections/items/index.ts | 6 + solution/site/src/sections/items/items.css | 24 ++ solution/site/src/seo/head.ts | 3 + solution/site/src/seo/verify.ts | 6 + 38 files changed, 2085 insertions(+), 774 deletions(-) create mode 100644 solution/site/src/sections/FestivalSection.tsx create mode 100644 solution/site/src/sections/SectionShell.tsx create mode 100644 solution/site/src/sections/StorySection.tsx create mode 100644 solution/site/src/sections/items/EventSection.tsx create mode 100644 solution/site/src/sections/items/ItinerarySection.tsx create mode 100644 solution/site/src/sections/items/TripMap.tsx create mode 100644 solution/site/src/sections/items/VideoSection.tsx diff --git a/solution/shared/src/lib/color.ts b/solution/shared/src/lib/color.ts index 821c275..4be2645 100644 --- a/solution/shared/src/lib/color.ts +++ b/solution/shared/src/lib/color.ts @@ -33,6 +33,13 @@ export function deriveSurfaces(colors: {bg: string; card: string; text: string; surface: mix(colors.bg, colors.text, 0.06), surfaceAlt: colors.card, inverse: '#1c1917', - border: colors.secondary, + /** + * 선 색. + * + * ★ secondary 를 그대로 쓰면 안 된다 — 그건 '본문 다음으로 진한 글자색' 이라 + * 선으로 쓰면 표와 카드가 격자무늬처럼 새까맣게 그어진다(갱지 팔레트에서 특히). + * 바탕에서 글자색 쪽으로 20%만 간 값이 목업의 선 색이다(#e4dac0 → #bcb49e). + */ + border: mix(colors.bg, colors.text, 0.2), }; } diff --git a/solution/shared/src/lib/section-data.ts b/solution/shared/src/lib/section-data.ts index be4c7ec..ce8cb5b 100644 --- a/solution/shared/src/lib/section-data.ts +++ b/solution/shared/src/lib/section-data.ts @@ -85,10 +85,16 @@ export interface PeopleItem { role?: string; oneLine?: string; /** - * 사진 검색어. ★ 이미지 URL 을 받지 않는다 — 초상권·저작권 확인은 사장님 몫이고, - * 모델이 지어낸 주소를 링크하면 깨진 사진이 인물 얼굴 자리에 남는다(LocalPlace.searchQuery 와 같은 규약). + * 사진 검색어. 모델은 이 값만 만든다 — 주소를 지어내면 깨진 사진이 인물 얼굴 자리에 남는다. */ imageQuery?: string; + /** + * 초상 사진. + * + * ★ **모델이 채우는 칸이 아니다.** 권리가 확인된 출처(위키미디어 등)에서 사람이 넣거나, + * 수집기가 라이선스를 확인하고 넣는다. 초상권은 지어낸 URL 로 감당할 수 없다. + */ + imageUrl?: string; verified?: DataVerified; source?: DataSource; } @@ -101,6 +107,8 @@ export interface ChronicleItem { place?: string; /** 도시의 성격을 바꾼 해. 레일의 붉은 점이 이 값이다 — 점의 색이 장식이 아니라 정보다. */ turning?: boolean; + /** 사진. 재게시 권리가 확실한 출처(공공누리·위키미디어)만 싣는다. 없으면 글자만 나간다. */ + imageUrl?: string; verified?: DataVerified; source?: DataSource; } @@ -124,6 +132,8 @@ export interface PostcardItem { place?: string; /** 소인에 찍을 짧은 지명. 없으면 place 가 그 자리에 들어간다. */ postmark?: string; + /** 엽서 앞면 사진. 권리가 확실한 출처만. 없으면 글자만 있는 뒷면 한 장이 된다. */ + imageUrl?: string; verified?: DataVerified; source?: DataSource; } @@ -138,6 +148,56 @@ export interface QuizItem { source?: DataSource; } +export interface ItineraryStop { + name: string; + minutes?: number; + note?: string; + searchQuery?: string; + /** + * 좌표. + * + * ★ **모델이 만드는 값이 아니다.** 지도에 핀을 찍는 값이라 한 자리만 틀려도 손님이 + * 엉뚱한 데로 간다. 장소명으로 공식 API(TourAPI·카카오)를 조회해 채운다. + * 없으면 지도를 그리지 않고 타임라인만 낸다. + */ + latitude?: number; + longitude?: number; + /** 사진. 권리가 확실한 출처만. */ + imageUrl?: string; +} + +export interface ItineraryDay { + label: string; + startTime?: string; + stops?: ItineraryStop[]; +} + +/** 며칠 묵느냐로 갈리는 일정. days 가 하루씩이다. */ +export interface ItineraryItem { + name: string; + duration?: string; + audience?: string; + why?: string; + days?: ItineraryDay[]; + verified?: DataVerified; + source?: DataSource; +} + +/** 사장님이 쓰는 공지·소식. 지역 리서치가 아니라 업소가 소유한 글이다. */ +export interface EventItem { + kind?: string; + title: string; + summary?: string; + body?: string; + verified?: DataVerified; +} + +export interface VideoItem { + url: string; + caption?: string; + verified?: DataVerified; +} + export interface PlannerStop { name: string; /** 여기서 머무는 시간(분). 없으면 60분으로 본다 — 못 재면 시각을 계산할 수 없다. */ @@ -181,6 +241,10 @@ export const SECTION_ITEM_REQUIRED_KEY: Record<string, string> = { postcard: 'line', quiz: 'question', planner: 'name', + // ★ 여기 없는 kind 는 파서가 통째로 건너뛴다 — payload 에 실려 와도 화면에서 사라진다. + itinerary: 'name', + event: 'title', + video: 'url', }; export interface ParsedSectionData<T> { diff --git a/solution/shared/src/types/builder.ts b/solution/shared/src/types/builder.ts index 15db900..dd491af 100644 --- a/solution/shared/src/types/builder.ts +++ b/solution/shared/src/types/builder.ts @@ -106,6 +106,13 @@ export interface TemplateLook { headingWeight: string; /** 섹션 세로 여백. 이 값 하나로 페이지의 호흡이 바뀐다. */ sectionSpace: string; + /** + * 종이 질감 — `background-image` 에 그대로 들어간다(`.paper`). + * + * ★ 레트로(갱지)의 인상이 사실상 이 값에서 온다. 없으면 색만 갱지고 면은 매끈해서 + * 같은 팔레트인데도 인쇄물로 안 보인다. 비워 두면 아무것도 깔지 않는다. + */ + texture?: string; } export interface PhotoItem { diff --git a/solution/shared/src/types/site-payload.ts b/solution/shared/src/types/site-payload.ts index ccd217c..1fd1bb8 100644 --- a/solution/shared/src/types/site-payload.ts +++ b/solution/shared/src/types/site-payload.ts @@ -189,6 +189,14 @@ export interface LocalPlace { description?: string; /** 외부 검색으로 보내는 질의어. 우리가 지어낸 URL 을 링크하지 않는다. */ searchQuery: string; + /** + * 대표 사진. 재게시 권리가 확실한 출처(공공누리 등)에서 온 것만 싣는다. + * + * ★ 없으면 카드가 글자만 남는다 — 그래도 지어내지 않는다. + */ + imageUrl?: string; + /** 사업장에서 잰 직선거리(m). 도보 시간을 화면에서 환산하는 근거다. */ + distanceMeters?: number; } export interface FestivalEntry { @@ -199,6 +207,8 @@ export interface FestivalEntry { description?: string; officialUrl?: string; searchQuery: string; + /** 대표 사진. 권리가 확실한 출처만 — 없으면 카드가 글자만 남는다. */ + imageUrl?: string; } export interface RouteEntry { diff --git a/solution/site/src/index.css b/solution/site/src/index.css index 7977d64..597707d 100644 --- a/solution/site/src/index.css +++ b/solution/site/src/index.css @@ -15,6 +15,28 @@ --tpl-card: #fafafa; --tpl-text: #09090b; --tpl-accent: #2563eb; + + /* + * 유동 타이포 — 화면 폭에 따라 자라는 글자 크기. + * + * ★ 이 토큰이 없던 동안 히어로 제목이 본문과 같은 크기로 나왔다. 섹션 컴포넌트들이 + * var(--fs-display) 를 쓰는데 정의가 어디에도 없어 그냥 무시됐다(브라우저는 조용히 넘어간다). + * ★ 값은 목업(/s/stay)이 쓰던 것을 그대로 옮겼다 — 화면이 달라지면 목업과 어긋난다. + */ + --fs-display: clamp(1.75rem, 1.25rem + 2.2vw, 3rem); + --fs-h2: clamp(1.3125rem, 1.15rem + 0.7vw, 1.75rem); + --fs-h3: clamp(1.0625rem, 1rem + 0.3vw, 1.25rem); + --fs-lead: clamp(0.9375rem, 0.9rem + 0.22vw, 1.0625rem); + --fs-body: clamp(0.9375rem, 0.91rem + 0.15vw, 1rem); + --fs-sm: clamp(0.8125rem, 0.8rem + 0.1vw, 0.875rem); + --fs-xs: clamp(0.75rem, 0.74rem + 0.06vw, 0.78125rem); + + /* 섹션 세로 여백. 템플릿의 sectionSpace 를 상한으로 두고 좁은 화면에서만 줄인다. */ + --section-space: clamp(2.25rem, 5vw, var(--tpl-section-space, 3.5rem)); + + /* 선·흐린 글자 — 템플릿 색에서 유도한다. 고정 회색을 쓰면 갱지 바탕에서 뜬다. */ + --color-line: var(--tpl-border, color-mix(in oklab, var(--tpl-text, #09090b) 14%, transparent)); + --color-muted: color-mix(in oklab, var(--tpl-text, #09090b) 60%, transparent); } @theme inline { @@ -68,4 +90,91 @@ body { padding-inline: 1.5rem; } } + + /* 본문 한 줄 길이 상한. 68자를 넘기면 눈이 다음 줄을 못 찾는다. */ + .measure { + max-width: 68ch; + } + + /* 종이 질감 — 템플릿이 texture 를 주면 깔린다(레트로 갱지). 없으면 아무것도 안 깔린다. */ + .paper { + background-image: var(--tpl-texture, none); + } + + /* 카드·패널. 바탕보다 한 겹 앞으로 나온 면. */ + .panel { + background-color: color-mix(in oklab, currentcolor 5%, transparent); + border: var(--tpl-border-width, 1px) solid var(--color-line); + border-radius: var(--tpl-radius, 0.75rem); + box-shadow: var(--tpl-shadow, none); + } + .panel-sunken { + background-color: color-mix(in oklab, currentcolor 6%, transparent); + } + + .border-line { + border-color: var(--color-line); + } + /* divide-y 의 사이 선도 같은 색으로. Tailwind 기본은 회색이라 갱지 위에서 뜬다. */ + .divide-line > :not([hidden]) ~ :not([hidden]) { + border-color: var(--color-line); + } + .text-muted { + color: var(--color-muted); + } + + /* 소제목. h1 은 히어로가 인라인으로 쓰고, 그 아래 단계는 이 두 클래스가 맡는다. */ + .h2 { + font-family: var(--tpl-font-heading, var(--font-serif)); + font-size: var(--fs-h2); + font-weight: var(--tpl-heading-weight, 700); + letter-spacing: var(--tpl-heading-tracking, -0.01em); + line-height: 1.2; + } + .h3 { + font-family: var(--tpl-font-heading, var(--font-serif)); + font-size: var(--fs-h3); + font-weight: var(--tpl-heading-weight, 700); + letter-spacing: var(--tpl-heading-tracking, -0.005em); + line-height: 1.35; + } + /* 간판체처럼 굵기가 한 벌뿐인 서체에 가짜 볼드가 씌워지는 것을 막는다. */ + .serif, + .h2, + .h3 { + font-synthesis-weight: none; + } + + .label { + font-size: var(--fs-xs); + color: var(--color-muted); + font-weight: 600; + } + + .tpl-shadow { + box-shadow: var(--tpl-shadow, none); + } +} + +@layer components { + /* + * 가로 레일 — 목업과 같은 클래스 이름. 캐러셀 라이브러리 없이 스크롤 스냅으로만 만든다. + * ★ 스크립트가 없어도 손가락·트랙패드로 밀린다. 라이브러리를 얹으면 그게 죽었을 때 + * 레일 전체가 한 줄로 굳는다. + */ + .slider-viewport { + overflow-x: auto; + scrollbar-width: none; + padding-bottom: 0.75rem; + } + .slider-viewport::-webkit-scrollbar { + display: none; + } + .slider-track { + display: flex; + scroll-snap-type: x mandatory; + } + .slider-track > * { + scroll-snap-align: center; + } } diff --git a/solution/site/src/pages/HomePage.tsx b/solution/site/src/pages/HomePage.tsx index cbc3635..c194473 100644 --- a/solution/site/src/pages/HomePage.tsx +++ b/solution/site/src/pages/HomePage.tsx @@ -5,6 +5,7 @@ import { EssentialInfoSection, ExhibitionSection, FaqSection, + FestivalSection, GallerySection, HeroSection, InquirySection, @@ -14,11 +15,20 @@ import { LocationSection, RulesSection, SpaceSection, + StorySection, UnitsSection, } from '@/sections'; import {useSite} from '@/lib/site-context'; import {isSectionEnabled} from '@/lib/derive'; +/** + * StorySection 이 탭으로 묶어 그리는 아이템. + * + * ★ `sections/StorySection.tsx` 의 STORY_KINDS 와 같은 목록이어야 한다. + * 어긋나면 어떤 아이템은 탭 밖에 따로 서고, 어떤 아이템은 어디에도 안 나온다. + */ +const STORY_KINDS = ['songs', 'people', 'chronicle', 'literature', 'postcard', 'quiz', 'daily']; + /** * 홈. * @@ -59,12 +69,16 @@ export function HomePage() { exhibition: ExhibitionSection, photos: GallerySection, local: LocalGuideSection, + festival: FestivalSection, weather: WeatherSection, map: LocationSection, faq: FaqSection, - // 붙여넣기 아이템 아홉. 데이터가 fact 가 아니라 theme.sections[].data 의 JSON 에서 온다. + // 붙여넣기 아이템. 데이터가 fact 가 아니라 theme.sections[].data 의 JSON 에서 온다. // ★ 같은 컴포넌트를 두 번 그리지 않는 아래 규칙과 상관없다 — 아이템마다 컴포넌트가 다르다. ...ITEM_SECTIONS, + // ★ 지역 이야기(가요·인물·연표·엽서·퀴즈·문학·일력)는 StorySection 이 탭으로 묶어 그린다. + // 그래서 그 일곱은 위 표에서 **가려낸다** — 여기 남겨 두면 탭 안과 밖에 두 번 나온다. + ...Object.fromEntries(STORY_KINDS.map((kind) => [kind, StorySection])), }; const rendered = new Set<string>(); diff --git a/solution/site/src/sections/AboutSection.tsx b/solution/site/src/sections/AboutSection.tsx index 8c74e49..09764a8 100644 --- a/solution/site/src/sections/AboutSection.tsx +++ b/solution/site/src/sections/AboutSection.tsx @@ -1,6 +1,13 @@ import {useSite} from '@/lib/site-context'; import {galleryImages, sectionBody} from '@/lib/derive'; +/** + * 소개. + * + * ★ 마크업은 목업(/s/stay 의 `<section id="about">`)에서 그대로 옮긴 것이다. + * 비슷하게 다시 짜지 않는다 — 그리드 비율(7:5)·모서리(rounded-xl)·본문 행간(1.8)이 + * 조금만 달라도 같은 페이지로 안 보인다. + */ export function AboutSection() { const payload = useSite(); const {narrative, place} = payload; @@ -17,15 +24,14 @@ export function AboutSection() { <section id="about" aria-labelledby="about-heading" - className="w-full border-b border-black/8 py-16 sm:py-24" + className="border-line paper w-full border-b" + style={{backgroundColor: 'var(--tpl-surface, #ffffff)', paddingBlock: 'var(--section-space)'}} > <div className="shell"> - <p className="mb-3 text-xs font-semibold uppercase tracking-wider opacity-50">About</p> - - <div className="grid grid-cols-1 items-center gap-8 md:grid-cols-12 lg:gap-12"> + <div className="grid grid-cols-1 items-center gap-8 lg:grid-cols-12 lg:gap-14"> {image && ( - <figure className="md:col-span-6"> - <div className="relative aspect-4/3 overflow-hidden rounded-2xl md:aspect-5/4"> + <figure className="lg:col-span-7"> + <div className="tpl-border border-line relative aspect-4/3 max-h-[26rem] overflow-hidden rounded-xl border lg:aspect-3/2"> <img src={image.url} alt={image.alt} @@ -36,20 +42,16 @@ export function AboutSection() { className="size-full object-cover" /> </div> - {image.caption && ( - <figcaption className="mt-2 text-xs opacity-60">{image.caption}</figcaption> - )} </figure> )} - <div className={image ? 'md:col-span-6' : 'md:col-span-12'}> - {/* 제목은 사장님이 붙인 섹션 이름이 먼저다. 이름이 비어 있을 때만 - 지금까지 쓰던 문구(한 줄 소개 → "○○ 소개")로 떨어진다. */} - <h2 id="about-heading" className="serif mb-4 text-2xl font-bold leading-snug tracking-tight sm:text-3xl lg:text-4xl"> - {narrative.heroSubline ?? `${place.name} 소개`} + <div className={image ? 'lg:col-span-5' : 'lg:col-span-12'}> + <h2 id="about-heading" className="h2 mb-6 flex items-center gap-2.5"> + <i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" /> + <span className="min-w-0">{`${place.name} 소개`}</span> </h2> - <div className="space-y-3 text-sm leading-relaxed opacity-80"> + <div className="measure space-y-4 text-[length:var(--fs-lead)] leading-[1.8]"> {about.map((paragraph, index) => ( <p key={index}>{paragraph}</p> ))} diff --git a/solution/site/src/sections/AnswerBlock.tsx b/solution/site/src/sections/AnswerBlock.tsx index e10325c..900cbec 100644 --- a/solution/site/src/sections/AnswerBlock.tsx +++ b/solution/site/src/sections/AnswerBlock.tsx @@ -11,6 +11,9 @@ import {formatKoreanDate} from '@/lib/format'; * 표(dl)로 두는 이유는 label-value 관계가 마크업으로 드러나야 기계가 짝을 짓기 때문이다. * * ★ 여기 나오는 값은 전부 확인된 fact 다. essentialRows() 가 걸러 준다. + * ★ 마크업은 목업(/s/stay 의 `<section id="summary">`)에서 그대로 옮겼다. + * 좌우 5:7 그리드에 왼쪽이 제목·요약, 오른쪽이 두 칸 표다. 제목은 h2 태그지만 h3 급으로 + * 그린다 — 이 블록은 섹션이 아니라 히어로에 딸린 요약이라 같은 크기로 서면 위계가 무너진다. */ export function AnswerBlock() { const payload = useSite(); @@ -23,33 +26,36 @@ export function AnswerBlock() { <section id="summary" aria-labelledby="summary-heading" - className="w-full border-b border-black/8 py-10 sm:py-12" - style={{backgroundColor: 'var(--color-surface-alt)'}} + className="border-line w-full border-b py-10 sm:py-14" + style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)'}} > <div className="shell"> - <h2 id="summary-heading" className="serif mb-1 text-lg font-bold sm:text-xl"> - 한눈에 보는 {payload.place.name} - </h2> - <p className="mb-5 text-xs opacity-60"> - {formatKoreanDate(payload.site.updatedAt)} 기준, 사업자가 확인한 정보입니다. - </p> + <div className="grid gap-8 lg:grid-cols-12 lg:gap-12"> + <div className="lg:col-span-5"> + <h2 id="summary-heading" className="h3"> + 한눈에 보는 {payload.place.name} + </h2> + {/* 단정문 한 덩어리 — 인용되기 좋은 형태. */} + <p className="measure mt-3 text-[length:var(--fs-body)] leading-relaxed">{summary}</p> + <p className="text-muted mt-3 text-[length:var(--fs-xs)]"> + {formatKoreanDate(payload.site.updatedAt)} 기준, 사업자가 확인한 정보입니다. + </p> + </div> - {/* 단정문 한 덩어리 — 인용되기 좋은 형태. */} - <p className="mb-5 max-w-3xl text-sm leading-relaxed sm:text-base">{summary}</p> - - {rows.length > 0 && ( - <dl className="grid grid-cols-1 gap-x-8 gap-y-0 sm:grid-cols-2"> - {rows.map((row) => ( - <div - key={row.label} - className="flex items-start justify-between gap-4 border-b border-black/8 py-2.5 text-sm" - > - <dt className="shrink-0 font-medium opacity-60">{row.label}</dt> - <dd className="text-right font-semibold">{row.value}</dd> - </div> - ))} - </dl> - )} + {rows.length > 0 && ( + <dl className="grid grid-cols-1 gap-x-10 lg:col-span-7 xl:grid-cols-2"> + {rows.map((row) => ( + <div + key={row.label} + className="border-line flex flex-col gap-0.5 py-2.5 sm:flex-row sm:gap-4 sm:border-b" + > + <dt className="text-muted shrink-0 text-[length:var(--fs-sm)] sm:w-28">{row.label}</dt> + <dd className="text-[length:var(--fs-sm)] font-semibold">{row.value}</dd> + </div> + ))} + </dl> + )} + </div> </div> </section> ); diff --git a/solution/site/src/sections/BookingSection.tsx b/solution/site/src/sections/BookingSection.tsx index d0e82b0..0ed3b33 100644 --- a/solution/site/src/sections/BookingSection.tsx +++ b/solution/site/src/sections/BookingSection.tsx @@ -28,8 +28,8 @@ export function BookingSection() { <section id="booking" aria-labelledby="booking-heading" - className="w-full border-b border-black/8 py-16 sm:py-24" - style={{backgroundColor: 'var(--color-surface-alt)'}} + className="paper border-line w-full border-b" + style={{backgroundColor: 'var(--color-surface-alt)', paddingBlock: 'var(--section-space)'}} > <div className="shell"> <p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50"> @@ -37,8 +37,9 @@ export function BookingSection() { <span>Booking</span> </p> - <h2 id="booking-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl"> - {sectionName(payload, 'booking', '예약 안내')} + <h2 id="booking-heading" className="h2 flex items-center gap-2.5"> + <i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" /> + <span className="min-w-0">{sectionName(payload, 'booking', '예약 안내')}</span> </h2> <p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm"> {payload.place.name} 예약은 아래 경로로 받습니다. diff --git a/solution/site/src/sections/EssentialInfoSection.tsx b/solution/site/src/sections/EssentialInfoSection.tsx index 7a6586a..a4f472b 100644 --- a/solution/site/src/sections/EssentialInfoSection.tsx +++ b/solution/site/src/sections/EssentialInfoSection.tsx @@ -1,82 +1,137 @@ -import {Info} from 'lucide-react'; +import {Phone} from 'lucide-react'; +import {selectPublishable} from '@o2o/shared'; import {useSite} from '@/lib/site-context'; -import {essentialRows} from '@/lib/derive'; +import {bookingLinks, channelLabel, essentialRows} from '@/lib/derive'; import {formatKoreanDate} from '@/lib/format'; /** - * 이용 정보 표 전체. + * 이용안내 및 예약. * - * AnswerBlock 이 상위 6개만 보여준다면 여기는 확인된 것 전부를 낸다. - * ★ "확인 안 된 항목이 N개 있다"를 숨기지 않는다 — 없는 척하면 - * 손님이 다른 데서 틀린 값을 찾아 온다. 있다고 말하고 문의로 보낸다. + * ★ 마크업은 목업(/s/stay 의 `<section id="info">`)에서 그대로 옮겼다. + * ★ 표를 두 덩이로 가른다 — "예약 전 확인"(critical)과 "시설 · 편의". + * critical 은 틀리면 예약 클레임이 나는 항목이라 손님이 먼저 봐야 하고, + * 와이파이·주차와 같은 무게로 섞이면 그 위계가 사라진다. + * ★ 예약 버튼이 이 섹션 안에 있다. 규정을 읽은 자리에서 바로 예약으로 이어지지 않으면 + * 손님은 다시 위로 올라가야 한다. + * ★ "확인 중인 항목 N개"를 숨기지 않는다 — 없는 척하면 손님이 다른 데서 틀린 값을 찾아 온다. */ export function EssentialInfoSection() { const payload = useSite(); const rows = essentialRows(payload); + if (rows.length === 0) return null; + + // critical 여부는 fact 에만 있다. 표 행(label/value)에 다시 붙여 둘로 가른다. + const criticalLabels = new Set( + selectPublishable(payload.facts) + .filter((fact) => fact.scope === 'place' && fact.critical) + .map((fact) => fact.label), + ); + const groups = [ + {title: '예약 전 확인', accent: true, rows: rows.filter((row) => criticalLabels.has(row.label))}, + {title: '시설 · 편의', accent: false, rows: rows.filter((row) => !criticalLabels.has(row.label))}, + ].filter((group) => group.rows.length > 0); + const hiddenCount = payload.facts.filter( (fact) => fact.scope === 'place' && !rows.some((row) => row.label === fact.label), ).length; - - if (rows.length === 0) return null; - - const half = Math.ceil(rows.length / 2); + const links = bookingLinks(payload); + const phone = payload.place.phone; return ( <section id="info" aria-labelledby="info-heading" - className="w-full border-b border-black/8 py-16 sm:py-24" - style={{backgroundColor: 'var(--color-surface-alt)'}} + className="border-line paper w-full border-b" + style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)', paddingBlock: 'var(--section-space)'}} > <div className="shell"> - <p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50"> - <Info className="size-4" /> - <span>Essential Information</span> - </p> - - <div className="mb-8 flex flex-col justify-between gap-4 md:flex-row md:items-end"> - <div> - <h2 id="info-heading" className="serif text-2xl font-bold tracking-tight sm:text-3xl"> - {'이용 및 예약 안내'} - </h2> - <p className="mt-1 text-xs opacity-60 sm:text-sm"> - 방문 전 확인이 필요한 운영 규정과 시설 안내입니다. - </p> - </div> + <div> + <header className="mb-6 sm:mb-8"> + <div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6"> + <div className="min-w-0"> + <h2 id="info-heading" className="h2 flex items-center gap-2.5"> + <i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" /> + <span className="min-w-0">이용안내 및 예약</span> + </h2> + <p className="text-muted measure mt-2 pl-[calc(3px+0.625rem)] text-[length:var(--fs-sm)]"> + 방문 전 확인이 필요한 운영 규정과 시설 안내입니다. + </p> + </div> + </div> + </header> </div> - <div - className="overflow-hidden rounded-2xl border border-black/8" - style={{backgroundColor: 'var(--color-surface)'}} - > - <div className="grid grid-cols-1 divide-y divide-black/8 md:grid-cols-2 md:divide-x md:divide-y-0"> - {[rows.slice(0, half), rows.slice(half)].map((column, columnIndex) => ( - <dl key={columnIndex} className="divide-y divide-black/5"> - {column.map((row) => ( + <div className="space-y-8"> + {groups.map((group) => ( + <section key={group.title}> + <h3 className="border-line mb-3 flex items-center gap-2 border-b pb-2 text-[length:var(--fs-sm)] font-bold"> + {group.accent && ( + <i + aria-hidden="true" + className="h-[1em] w-[3px] shrink-0" + style={{backgroundColor: 'var(--color-accent)'}} + /> + )} + <span>{group.title}</span> + </h3> + <dl className="divide-line divide-y"> + {group.rows.map((row) => ( <div key={row.label} - className="flex flex-col justify-between gap-1 p-4 sm:flex-row sm:items-start sm:gap-4 sm:p-5" + className="flex flex-col gap-1 py-3.5 sm:flex-row sm:items-baseline sm:gap-6" > - <dt className="shrink-0 text-xs font-bold sm:w-32 sm:text-sm">{row.label}</dt> - <dd className="flex-1 text-left sm:text-right"> - <p className="text-xs font-medium sm:text-sm">{row.value}</p> - {row.note && <p className="mt-0.5 text-[11px] opacity-50">{row.note}</p>} - </dd> + <dt className="text-muted text-[length:var(--fs-sm)] sm:w-40 sm:shrink-0"> + {row.label} + </dt> + <dd className="measure text-[length:var(--fs-sm)] font-semibold">{row.value}</dd> </div> ))} </dl> - ))} - </div> + </section> + ))} - <div className="flex flex-col items-center justify-between gap-2 border-t border-black/8 px-4 py-3.5 text-xs opacity-60 sm:flex-row sm:px-6"> + {(phone || links.length > 0) && ( + <div className="border-line flex flex-col gap-3 border-t pt-6 sm:flex-row sm:items-center sm:justify-between"> + <p className="text-[length:var(--fs-sm)] font-semibold"> + {payload.place.name} 예약은 아래로 받습니다. + </p> + <div className="flex flex-wrap gap-2"> + {phone && ( + <a + href={`tel:${phone}`} + className="tap border-line tpl-border inline-flex items-center justify-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold" + > + <Phone className="size-4" aria-hidden="true" /> + <span>{phone}</span> + </a> + )} + {links.map((link) => ( + <a + key={link.url} + href={link.url} + target="_blank" + rel="noopener noreferrer" + className="tap inline-flex items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90" + style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}} + > + {channelLabel(link)}로 예약 + </a> + ))} + </div> + </div> + )} + </div> + + <p className="text-muted mt-5 text-[length:var(--fs-xs)]"> + <span className="flex flex-col gap-1 sm:flex-row sm:justify-between"> <span> {hiddenCount > 0 ? `확인 중인 항목 ${hiddenCount}개는 표시하지 않았습니다. 필요하시면 전화로 문의해 주세요.` : '모든 항목이 사업자 확인을 거쳤습니다.'} </span> <span className="font-medium">{formatKoreanDate(payload.site.updatedAt)} 기준</span> - </div> - </div> + </span> + </p> </div> </section> ); diff --git a/solution/site/src/sections/ExhibitionSection.tsx b/solution/site/src/sections/ExhibitionSection.tsx index c8fd8d3..726c9c0 100644 --- a/solution/site/src/sections/ExhibitionSection.tsx +++ b/solution/site/src/sections/ExhibitionSection.tsx @@ -27,8 +27,8 @@ export function ExhibitionSection() { <section id="exhibition" aria-labelledby="exhibition-heading" - className="w-full border-b border-black/8 py-16 sm:py-24" - style={{backgroundColor: 'var(--color-surface-alt)'}} + className="paper border-line w-full border-b" + style={{backgroundColor: 'var(--color-surface-alt)', paddingBlock: 'var(--section-space)'}} > <div className="shell"> <p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50"> @@ -36,8 +36,9 @@ export function ExhibitionSection() { <span>Exhibition</span> </p> - <h2 id="exhibition-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl"> - {sectionName(payload, 'exhibition', '관람 안내')} + <h2 id="exhibition-heading" className="h2 flex items-center gap-2.5"> + <i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" /> + <span className="min-w-0">{sectionName(payload, 'exhibition', '관람 안내')}</span> </h2> <p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm"> 방문 전 관람 조건을 확인해 주세요. diff --git a/solution/site/src/sections/FaqSection.tsx b/solution/site/src/sections/FaqSection.tsx index 06e1983..478fd59 100644 --- a/solution/site/src/sections/FaqSection.tsx +++ b/solution/site/src/sections/FaqSection.tsx @@ -1,4 +1,3 @@ -import {HelpCircle} from 'lucide-react'; import {useSite} from '@/lib/site-context'; import {faqList} from '@/lib/derive'; @@ -20,36 +19,42 @@ export function FaqSection() { <section id="faq" aria-labelledby="faq-heading" - className="w-full border-b border-black/8 py-16 sm:py-24" - style={{backgroundColor: 'var(--color-surface-alt)'}} + className="border-line paper w-full border-b" + style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)', paddingBlock: 'var(--section-space)'}} > <div className="shell"> - <p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50"> - <HelpCircle className="size-4" /> - <span>FAQ</span> - </p> + <div> + <header className="mb-6 sm:mb-8"> + <div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6"> + <div className="min-w-0"> + <h2 id="faq-heading" className="h2 flex items-center gap-2.5"> + <i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" /> + <span className="min-w-0">자주 묻는 질문</span> + </h2> + <p className="text-muted measure mt-2 pl-[calc(3px+0.625rem)] text-[length:var(--fs-sm)]"> + 아래 답변은 모두 사업자가 확인한 내용입니다. + </p> + </div> + </div> + </header> + </div> - <h2 id="faq-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl"> - {'자주 묻는 질문'} - </h2> - <p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm"> - 아래 답변은 모두 사업자가 확인한 내용입니다. - </p> - - <div className="max-w-3xl space-y-3"> + {/* ★ 두 칸으로 편다. 한 줄로 세우면 질문 열 개가 화면 두 개를 잡아먹는다. + `h-fit` 이라 펼친 카드만 길어지고 옆 카드는 따라 늘어나지 않는다. */} + <div className="grid gap-3 lg:grid-cols-2 lg:gap-4"> {faqs.map((faq, index) => ( <details key={faq.faqId} // 첫 항목만 펼쳐 둔다 — 나머지도 DOM 에는 그대로 있다. open={index === 0} - className="overflow-hidden rounded-2xl border border-black/8" - style={{backgroundColor: 'var(--color-surface)'}} + className="panel group h-fit overflow-hidden" > - <summary className="flex cursor-pointer items-center gap-2.5 p-4 text-xs font-bold marker:content-none sm:p-5 sm:text-sm [&::-webkit-details-marker]:hidden"> - <span className="serif shrink-0 opacity-50">Q.</span> - <span>{faq.question}</span> + <summary className="tap flex cursor-pointer items-center gap-3 px-5 text-[length:var(--fs-sm)] font-bold marker:content-none [&::-webkit-details-marker]:hidden"> + <span className="serif text-muted shrink-0">Q.</span> + <span className="flex-1">{faq.question}</span> + <span className="text-muted shrink-0 transition-transform group-open:rotate-45">+</span> </summary> - <div className="border-t border-black/5 px-4 pb-4 pt-3 text-xs leading-relaxed opacity-80 sm:px-5 sm:pb-5 sm:text-sm"> + <div className="border-line border-t px-5 pb-5 pt-4 text-[length:var(--fs-sm)] leading-relaxed opacity-80"> {faq.answer} </div> </details> diff --git a/solution/site/src/sections/FestivalSection.tsx b/solution/site/src/sections/FestivalSection.tsx new file mode 100644 index 0000000..2bf282e --- /dev/null +++ b/solution/site/src/sections/FestivalSection.tsx @@ -0,0 +1,162 @@ +/** + * 계절별 축제. + * + * ★ 왜 계절로 묶나 — 지역 축제는 날짜보다 '언제쯤'이 먼저다. 손님은 "가을에 뭐 있나"를 묻지 + * "10월 3일에 뭐 있나"를 묻지 않는다. 그리고 정적 페이지는 몇 달 산다 — 날짜로 줄 세우면 + * 구운 다음 날부터 지난 목록이 된다. + * ★ **전 계절을 HTML 에 굽고 화면에서만 접는다.** 접힌 계절이 HTML 에 없으면 검색·AI 가 + * 나머지를 못 읽는다(items 규칙과 같다). + * ★ 링크는 검색으로 보낸다 — 축제 공식 페이지 주소를 우리가 지어내지 않는다. + */ +import {useState} from 'react'; +import {ArrowUpRight, MapPin} from 'lucide-react'; +import {useSite} from '@/lib/site-context'; +import {sectionName} from '@/lib/derive'; + +const SEASONS = ['봄', '여름', '가을', '겨울'] as const; +type Season = (typeof SEASONS)[number] | '전체'; + +/** '3월' → 봄. 월을 못 읽으면 어느 계절로도 넣지 않는다(지어내지 않는다). */ +function seasonOf(month?: string): (typeof SEASONS)[number] | null { + const n = Number((month ?? '').replace(/[^0-9]/g, '')); + if (!Number.isFinite(n) || n < 1 || n > 12) return null; + if (n >= 3 && n <= 5) return '봄'; + if (n >= 6 && n <= 8) return '여름'; + if (n >= 9 && n <= 11) return '가을'; + return '겨울'; +} + +export function FestivalSection() { + const payload = useSite(); + const festivals = payload.local.festivals ?? []; + const [active, setActive] = useState<Season>('전체'); + const region = payload.place.addressLocality ?? payload.place.addressRegion ?? ''; + + if (festivals.length === 0) return null; + + const grouped = SEASONS.map((season) => ({ + season, + items: festivals.filter((f) => seasonOf(f.month) === season), + })).filter((group) => group.items.length > 0); + const unknown = festivals.filter((f) => seasonOf(f.month) === null); + + return ( + <section + id="festival" + aria-labelledby="festival-heading" + className="border-line paper w-full border-b" + style={{backgroundColor: 'var(--tpl-surface, #ffffff)', paddingBlock: 'var(--section-space)'}} + > + <div className="shell"> + <header className="mb-6 sm:mb-8"> + <h2 id="festival-heading" className="h2 flex items-center gap-2.5"> + <i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" /> + <span className="min-w-0">{sectionName(payload, 'festival', '계절별 축제')}</span> + </h2> + <p className="text-muted measure mt-2 pl-[calc(3px+0.625rem)] text-[length:var(--fs-sm)]"> + {region}의 축제와 행사를 계절로 묶었습니다. + </p> + </header> + + <div className="mb-6 flex flex-wrap gap-1.5" role="tablist" aria-label="계절"> + {[...grouped.map((g) => g.season), '전체' as const].map((season) => ( + <button + key={season} + type="button" + role="tab" + aria-selected={season === active} + onClick={() => setActive(season)} + className="border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80" + style={ + season === active + ? { + backgroundColor: 'var(--tpl-text, #09090b)', + color: 'var(--tpl-bg, #ffffff)', + borderColor: 'var(--tpl-text, #09090b)', + } + : undefined + } + > + {season} + </button> + ))} + </div> + + <div className="space-y-8"> + {[...grouped, ...(unknown.length > 0 ? [{season: '그 밖에' as const, items: unknown}] : [])].map( + (group) => ( + <div key={group.season} hidden={active !== '전체' && active !== group.season}> + <h3 className="border-line mb-4 border-b pb-2 text-[length:var(--fs-sm)] font-bold"> + {group.season} + </h3> + <ul className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 sm:gap-3 lg:grid-cols-3 lg:gap-4"> + {group.items.map((festival) => ( + <li key={festival.name}> + <a + href={`https://search.naver.com/search.naver?query=${encodeURIComponent(festival.searchQuery)}`} + target="_blank" + rel="noopener noreferrer nofollow" + className="panel group flex h-full overflow-hidden transition-opacity hover:opacity-85 sm:flex-col" + > + {festival.imageUrl && ( + <span className="relative block aspect-square w-28 shrink-0 overflow-hidden sm:aspect-4/3 sm:w-auto"> + <img + src={festival.imageUrl} + alt={`${festival.name} 사진`} + loading="lazy" + decoding="async" + className="size-full object-cover transition-transform duration-500 group-hover:scale-105" + /> + <span + className="absolute bottom-2 left-2 rounded-md px-2 py-0.5 text-[length:var(--fs-xs)] font-bold" + style={{ + backgroundColor: + 'color-mix(in srgb, var(--tpl-inverse, #1c1917) 78%, transparent)', + color: 'var(--tpl-bg, #fff)', + }} + > + {festival.month} + </span> + </span> + )} + <span className="flex min-w-0 flex-1 flex-col gap-1 p-3 sm:p-3.5"> + <span className="text-[length:var(--fs-sm)] font-bold">{festival.name}</span> + {/* ★ 기간은 있을 때만. 지난 날짜를 걸어두면 그것도 틀린 안내다. */} + {festival.period && ( + <span className="text-[length:var(--fs-xs)] font-medium opacity-80"> + {festival.period} + </span> + )} + {!festival.imageUrl && ( + <span className="text-[length:var(--fs-xs)] font-bold opacity-60"> + {festival.month} + </span> + )} + {festival.location && ( + <span className="text-muted flex items-start gap-1 text-[length:var(--fs-xs)]"> + <MapPin className="mt-0.5 size-3 shrink-0" aria-hidden="true" /> + <span>{festival.location}</span> + </span> + )} + {festival.description && ( + <span className="text-muted line-clamp-2 pt-0.5 text-[length:var(--fs-xs)] leading-relaxed sm:line-clamp-3"> + {festival.description} + </span> + )} + <span className="text-muted mt-auto hidden items-center gap-0.5 pt-1.5 text-[length:var(--fs-xs)] opacity-70 transition-opacity group-hover:opacity-100 sm:flex"> + <span>검색으로 열기</span> + <ArrowUpRight className="size-3" aria-hidden="true" /> + </span> + </span> + </a> + </li> + ))} + </ul> + </div> + ), + )} + </div> + </div> + </section> + ); +} diff --git a/solution/site/src/sections/GallerySection.tsx b/solution/site/src/sections/GallerySection.tsx index 1f35278..decf854 100644 --- a/solution/site/src/sections/GallerySection.tsx +++ b/solution/site/src/sections/GallerySection.tsx @@ -1,38 +1,35 @@ -import {useCallback, useEffect, useState} from 'react'; -import useEmblaCarousel from 'embla-carousel-react'; +import {useRef, useState} from 'react'; import {ChevronLeft, ChevronRight, X} from 'lucide-react'; import {useSite} from '@/lib/site-context'; -import {galleryImages} from '@/lib/derive'; +import {galleryImages, sectionName} from '@/lib/derive'; +/** + * 사진 갤러리. + * + * ★ 마크업은 목업(/s/stay 의 `<section id="gallery">`)에서 그대로 옮겼다. + * ★ 좁은 화면은 가로로 미는 한 줄, `lg` 이상은 4열 격자다. 캐러셀 라이브러리를 쓰지 않는다 — + * `scroll-snap` 만으로 같은 동작이 나오고, 스크립트가 없어도 사진이 다 보인다. + * ★ 전 장을 HTML 에 편다. 접거나 지연 삽입하면 크롤러가 나머지를 못 읽는다. + */ export function GallerySection() { const payload = useSite(); const images = galleryImages(payload); - const setting = payload.theme.sections.find((section) => section.id === 'photos'); - const variantId = setting?.variantId ?? 'photos.grid'; + const railRef = useRef<HTMLUListElement>(null); + const [index, setIndex] = useState(0); const [openIndex, setOpenIndex] = useState<number | null>(null); - /** - * ★ 슬라이드를 화면 폭보다 좁게 잡아 **양옆이 보이게** 한다. - * 100% 폭이면 정지 화면에서 사진 한 장과 구분이 안 된다 — 손님은 옆으로 더 있다는 걸 - * 모르고 지나간다. 옆 사진이 살짝 걸쳐 보이는 것이 "밀어서 볼 수 있다"는 유일한 신호다. - */ - const [carouselRef, carouselApi] = useEmblaCarousel({loop: true, align: 'center', skipSnaps: false}); - const [selected, setSelected] = useState(0); - - // 화살표·드래그·키보드 어느 쪽으로 움직여도 인디케이터가 따라오게 한다. - useEffect(() => { - if (!carouselApi) return; - const sync = () => setSelected(carouselApi.selectedScrollSnap()); - sync(); - carouselApi.on('select', sync); - return () => { - carouselApi.off('select', sync); - }; - }, [carouselApi]); - - const scrollTo = useCallback((index: number) => carouselApi?.scrollTo(index), [carouselApi]); if (images.length === 0) return null; + const onScroll = () => { + const rail = railRef.current; + if (!rail) return; + setIndex(Math.round(rail.scrollLeft / Math.max(1, rail.clientWidth))); + }; + const move = (delta: number) => { + const rail = railRef.current; + if (!rail) return; + rail.scrollTo({left: (index + delta) * rail.clientWidth, behavior: 'smooth'}); + }; const step = (delta: number) => { setOpenIndex((current) => current === null ? null : (current + delta + images.length) % images.length, @@ -43,105 +40,91 @@ export function GallerySection() { <section id="gallery" aria-labelledby="gallery-heading" - className="w-full border-b border-black/8 py-16 sm:py-24" + className="border-line paper w-full border-b" + style={{backgroundColor: 'var(--tpl-surface, #ffffff)', paddingBlock: 'var(--section-space)'}} > <div className="shell"> - <p className="mb-2 text-xs font-semibold uppercase tracking-wider opacity-50">Gallery</p> - <h2 id="gallery-heading" className="serif mb-8 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl"> - {setting?.name || '공간 갤러리'} - </h2> - - {/* 비전 분석 결과는 검색·접근성 메타데이터로만 사용하고 화면에는 사진만 보인다. */} - {variantId === 'photos.carousel' ? ( - <div - className="relative mx-auto max-w-5xl" - role="group" - aria-roledescription="캐러셀" - aria-label={`사진 ${images.length}장`} - // 좌우 키로도 넘길 수 있어야 한다 — 마우스가 없는 사람에게 화살표 버튼만 남기지 않는다. - tabIndex={0} - onKeyDown={(event) => { - if (event.key === 'ArrowLeft') carouselApi?.scrollPrev(); - if (event.key === 'ArrowRight') carouselApi?.scrollNext(); - }} - > - {/* 양옆 사진이 걸쳐 보이도록 컨테이너 밖으로 넘치는 부분만 잘라낸다. */} - <div ref={carouselRef} className="overflow-hidden"> - <ul className="-ml-3 flex touch-pan-y sm:-ml-4"> - {images.map((image, index) => ( - <li - key={image.mediaId} - className="min-w-0 flex-[0_0_88%] pl-3 sm:flex-[0_0_72%] sm:pl-4" - aria-roledescription="슬라이드" - aria-label={`${index + 1} / ${images.length}`} - > - <GalleryImage - image={image} - onOpen={() => setOpenIndex(index)} - // 지금 보고 있지 않은 사진은 눌러서 흐리게 — 어느 것이 현재인지 눈에 바로 들어온다. - className={`aspect-16/9 rounded-2xl transition-opacity duration-300 ${ - index === selected ? 'opacity-100' : 'opacity-45' - }`} - /> - </li> - ))} - </ul> + <div> + <header className="mb-6 sm:mb-8"> + <div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6"> + <div className="min-w-0"> + <h2 id="gallery-heading" className="h2 flex items-center gap-2.5"> + <i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" /> + <span className="min-w-0">{sectionName(payload, 'photos', '사진 갤러리')}</span> + </h2> + </div> </div> + </header> + </div> - {images.length > 1 && ( - <> - <CarouselButton label="이전 사진" side="left" onClick={() => carouselApi?.scrollPrev()} /> - <CarouselButton label="다음 사진" side="right" onClick={() => carouselApi?.scrollNext()} /> - - {/* 몇 장 중 몇 번째인지. 점만 있으면 사진이 많을 때 셀 수 없다. */} - <div className="mt-4 flex items-center justify-center gap-3"> - <ul className="flex items-center gap-1.5"> - {images.map((image, index) => ( - <li key={image.mediaId}> - <button - type="button" - onClick={() => scrollTo(index)} - aria-label={`${index + 1}번째 사진으로`} - aria-current={index === selected} - className={`h-1.5 rounded-full transition-all ${ - index === selected ? 'w-5 bg-black/70' : 'w-1.5 bg-black/20 hover:bg-black/40' - }`} - /> - </li> - ))} - </ul> - <span className="text-xs tabular-nums opacity-50"> - {selected + 1} / {images.length} - </span> - </div> - </> - )} - </div> - ) : variantId === 'photos.masonry' ? ( - <ul className="columns-2 gap-2 sm:columns-3 sm:gap-4 [&>li]:mb-2 sm:[&>li]:mb-4"> - {images.map((image, index) => ( - <li key={image.mediaId} className="break-inside-avoid"> - <GalleryImage image={image} onOpen={() => setOpenIndex(index)} className="rounded-xl" free /> + <div className="relative"> + <ul + ref={railRef} + onScroll={onScroll} + className="flex snap-x snap-mandatory gap-2 overflow-x-auto [scrollbar-width:none] lg:grid lg:snap-none lg:grid-cols-4 lg:gap-3 lg:overflow-visible [&::-webkit-scrollbar]:hidden" + > + {images.map((image, i) => ( + <li key={image.mediaId} className="w-full shrink-0 snap-center lg:w-auto"> + <button + type="button" + onClick={() => setOpenIndex(i)} + className="border-line tpl-border group relative block aspect-4/3 w-full cursor-pointer overflow-hidden rounded-lg border" + aria-label={`${image.alt} 크게 보기`} + > + <img + src={image.url} + alt={image.alt} + loading="lazy" + decoding="async" + width={image.width} + height={image.height} + className="size-full object-cover transition-transform duration-500 group-hover:scale-105" + /> + </button> </li> ))} </ul> - ) : ( - <ul className="grid grid-cols-3 gap-2 sm:gap-4 md:grid-cols-4"> - {images.map((image, index) => ( - <li key={image.mediaId}> - <GalleryImage image={image} onOpen={() => setOpenIndex(index)} className="aspect-square rounded-xl" /> - </li> - ))} - </ul> - )} + + {images.length > 1 && ( + <> + <button + type="button" + aria-label="이전 사진" + aria-hidden={index === 0} + tabIndex={index === 0 ? -1 : 0} + onClick={() => move(-1)} + className={`absolute left-2 top-1/2 z-10 flex size-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 lg:hidden ${ + index === 0 ? 'pointer-events-none opacity-0' : 'opacity-100' + }`} + > + <ChevronLeft className="size-4" aria-hidden="true" /> + </button> + <button + type="button" + aria-label="다음 사진" + aria-hidden={index >= images.length - 1} + tabIndex={index >= images.length - 1 ? -1 : 0} + onClick={() => move(1)} + className={`absolute right-2 top-1/2 z-10 flex size-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 lg:hidden ${ + index >= images.length - 1 ? 'pointer-events-none opacity-0' : 'opacity-100' + }`} + > + <ChevronRight className="size-4" aria-hidden="true" /> + </button> + <span className="pointer-events-none absolute bottom-3 right-3 rounded-full bg-black/55 px-2.5 py-1 text-[length:var(--fs-xs)] font-semibold tabular-nums text-white lg:hidden"> + {index + 1} / {images.length} + </span> + </> + )} + </div> </div> {openIndex !== null && ( <div + className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4" role="dialog" aria-modal="true" aria-label="사진 크게 보기" - className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4 backdrop-blur-md" onClick={() => setOpenIndex(null)} > <button @@ -152,7 +135,6 @@ export function GallerySection() { > <X className="size-6" /> </button> - <button type="button" onClick={(event) => { @@ -164,7 +146,6 @@ export function GallerySection() { > <ChevronLeft className="size-6" /> </button> - <button type="button" onClick={(event) => { @@ -176,7 +157,6 @@ export function GallerySection() { > <ChevronRight className="size-6" /> </button> - <div className="flex max-h-[85vh] max-w-4xl items-center justify-center" onClick={(event) => event.stopPropagation()} @@ -192,43 +172,3 @@ export function GallerySection() { </section> ); } - -function GalleryImage({image, onOpen, className, free = false}: { - image: ReturnType<typeof galleryImages>[number]; - onOpen: () => void; - className: string; - free?: boolean; -}) { - return ( - <button - type="button" - onClick={onOpen} - className={`group relative block w-full cursor-pointer overflow-hidden border border-black/8 ${className}`} - aria-label={`${image.alt} 크게 보기`} - > - <img - src={image.url} - alt={image.alt} - loading="lazy" - decoding="async" - width={image.width} - height={image.height} - className={free ? 'h-auto w-full' : 'size-full object-cover transition-transform duration-500 group-hover:scale-105'} - /> - </button> - ); -} - -function CarouselButton({label, side, onClick}: {label: string; side: 'left' | 'right'; onClick: () => void}) { - const Icon = side === 'left' ? ChevronLeft : ChevronRight; - return ( - <button - type="button" - onClick={onClick} - aria-label={label} - className={`absolute top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/55 p-2.5 text-white backdrop-blur transition-colors hover:bg-black/75 ${side === 'left' ? 'left-3' : 'right-3'}`} - > - <Icon className="size-5" /> - </button> - ); -} diff --git a/solution/site/src/sections/HeroSection.tsx b/solution/site/src/sections/HeroSection.tsx index 53d7a19..e60b0a8 100644 --- a/solution/site/src/sections/HeroSection.tsx +++ b/solution/site/src/sections/HeroSection.tsx @@ -1,78 +1,102 @@ -import {ArrowRight, MapPin} from 'lucide-react'; +import {ChevronDown, MapPin} from 'lucide-react'; import {useSite} from '@/lib/site-context'; import {primaryImage} from '@/seo/meta'; import {unitSpec} from '@/lib/derive'; +/** + * 첫 화면. + * + * ★ 가운데 정렬이 아니라 **좌하단**이다. 사진이 주인공인 화면에서 글자를 한가운데 얹으면 + * 사진의 피사체를 정확히 가린다 — 실제로 그 이유로 좌하단으로 내렸다(/s/stay). + * ★ 색은 전부 템플릿 토큰이다. 여기에 zinc-950 같은 고정색을 쓰면 레트로(갱지)를 골라도 + * 첫 화면만 검게 남는다. + * ★ 버튼을 두 개 세우지 않는다. 첫 화면에서 물어볼 것은 "방을 보겠는가" 하나다. + */ export function HeroSection() { const payload = useSite(); const {place, narrative} = payload; const image = primaryImage(payload); const spec = unitSpec(payload); + const region = place.addressLocality ?? place.addressRegion; + const subline = narrative.tagline ?? narrative.heroSubline; // id="top" — 상단 로고와 하단 탭의 '홈' 이 여기로 돌아온다. return ( - <section - id="top" - className="relative flex min-h-[420px] w-full items-center justify-center overflow-hidden bg-zinc-950 md:min-h-[580px] md:h-[78vh]" - > - {image && ( - <div className="absolute inset-0 z-0"> - <img - src={image.url} - alt={image.alt} - // 첫 화면 이미지는 지연 로딩하지 않는다 — LCP 가 그만큼 늦어진다. - fetchPriority="high" - decoding="async" - width={image.width} - height={image.height} - className="size-full object-cover object-center" - /> - <div className="absolute inset-0 bg-gradient-to-t from-zinc-950 via-zinc-950/60 to-zinc-950/30" /> - </div> - )} - - <div className="shell relative z-10 flex flex-col items-center py-16 text-center text-white"> - {(place.addressLocality ?? place.addressRegion) && ( - <p className="mb-4 inline-flex items-center gap-1.5 rounded-full border border-white/20 bg-white/10 px-3 py-1 text-xs backdrop-blur-md sm:mb-6 sm:text-sm"> - <MapPin className="size-3.5" /> - <span>{place.addressLocality ?? place.addressRegion}</span> - </p> + <section id="top" className="w-full"> + <div + className="relative flex w-full items-end overflow-hidden" + style={{ + backgroundColor: 'var(--tpl-inverse, #1c1917)', + color: 'var(--tpl-bg, #ffffff)', + minHeight: 'clamp(24rem, 62vh, 36rem)', + }} + > + {image && ( + <div className="absolute inset-0 z-0"> + <img + src={image.url} + alt={image.alt} + // 첫 화면 이미지는 지연 로딩하지 않는다 — LCP 가 그만큼 늦어진다. + fetchPriority="high" + decoding="async" + width={image.width} + height={image.height} + className="size-full object-cover object-center" + /> + {/* 글자가 놓이는 아래쪽만 짙게. 위쪽은 사진을 그대로 보여준다. */} + <div + className="absolute inset-0" + style={{ + background: + 'linear-gradient(to top, color-mix(in srgb, var(--tpl-inverse, #1c1917) 88%, transparent) 0%,' + + ' color-mix(in srgb, var(--tpl-inverse, #1c1917) 45%, transparent) 34%, transparent 66%)', + }} + /> + </div> )} - {/* h1 은 페이지에 하나. 가게 이름이 들어가야 "○○ 홈페이지" 질의에 잡힌다. */} - <h1 className="serif mb-4 text-2xl font-medium leading-tight tracking-tight sm:text-4xl md:text-5xl lg:text-6xl"> - {narrative.heroHeadline ? ( - <> - <span className="block">{narrative.heroHeadline}</span> - <span className="mt-1 block font-semibold">{place.name}</span> - </> - ) : ( - place.name + <div className="shell relative z-10 pb-10 pt-24 sm:pb-12"> + {region && ( + <p className="mb-3 inline-flex items-center gap-1 text-[length:var(--fs-xs)] opacity-80"> + <MapPin className="size-3.5" aria-hidden="true" /> + <span>{region}</span> + </p> )} - </h1> - {(narrative.tagline ?? narrative.heroSubline) && ( - <p className="mb-8 max-w-2xl text-xs font-light leading-relaxed text-white/80 sm:text-base md:text-lg"> - {narrative.tagline ?? narrative.heroSubline} - </p> - )} + {/* h1 은 페이지에 하나. 가게 이름이 들어가야 "○○ 홈페이지" 질의에 잡힌다. */} + <h1 + className="serif" + style={{ + fontSize: 'var(--fs-display)', + fontWeight: 'var(--tpl-heading-weight, 700)', + lineHeight: 1.15, + }} + > + {narrative.heroHeadline ? ( + <> + <span className="block">{narrative.heroHeadline}</span> + <span className="mt-1 block">{place.name}</span> + </> + ) : ( + place.name + )} + </h1> + + {subline && ( + <p className="measure mt-3 text-[length:var(--fs-lead)] leading-relaxed opacity-85"> + {subline} + </p> + )} - <div className="flex w-full flex-col items-center gap-3 sm:w-auto sm:flex-row"> {payload.units.length > 0 && ( <a href="#units" - className="flex w-full items-center justify-center gap-2 rounded-xl bg-white px-6 py-3 text-xs font-medium text-zinc-950 transition-all hover:bg-white/90 sm:w-auto sm:text-sm" + className="mt-6 inline-flex items-center gap-1 text-[length:var(--fs-sm)] font-medium underline-offset-4 opacity-80 transition-opacity hover:opacity-100" > - <span>{spec.label} 둘러보기</span> - <ArrowRight className="size-4" /> + <span>{spec.label} 보기</span> + <ChevronDown className="size-4" aria-hidden="true" /> </a> )} - <a - href="#info" - className="flex w-full items-center justify-center gap-2 rounded-xl border border-white/30 bg-white/15 px-6 py-3 text-xs font-medium backdrop-blur-md transition-all hover:bg-white/25 sm:w-auto sm:text-sm" - > - <span>이용 정보 보기</span> - </a> </div> </div> </section> diff --git a/solution/site/src/sections/InquirySection.tsx b/solution/site/src/sections/InquirySection.tsx index cba1492..95f435b 100644 --- a/solution/site/src/sections/InquirySection.tsx +++ b/solution/site/src/sections/InquirySection.tsx @@ -26,7 +26,7 @@ export function InquirySection() { <section id="inquiry" aria-labelledby="inquiry-heading" - className="w-full border-b border-black/8 py-16 sm:py-24" + className="paper border-line w-full border-b" > <div className="shell"> <p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50"> @@ -34,8 +34,9 @@ export function InquirySection() { <span>Contact</span> </p> - <h2 id="inquiry-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl"> - {sectionName(payload, 'inquiry', '문의 안내')} + <h2 id="inquiry-heading" className="h2 flex items-center gap-2.5"> + <i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" /> + <span className="min-w-0">{sectionName(payload, 'inquiry', '문의 안내')}</span> </h2> <p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm"> 궁금한 점은 아래로 연락 주시면 {place.name}에서 직접 안내해 드립니다. @@ -43,7 +44,7 @@ export function InquirySection() { <ul className="max-w-3xl divide-y divide-black/5 overflow-hidden rounded-2xl border border-black/8" - style={{backgroundColor: 'var(--color-surface-alt)'}} + style={{backgroundColor: 'var(--color-surface-alt)', paddingBlock: 'var(--section-space)'}} > {place.phone && ( <li> diff --git a/solution/site/src/sections/LocalGuideSection.tsx b/solution/site/src/sections/LocalGuideSection.tsx index 453239a..3113f12 100644 --- a/solution/site/src/sections/LocalGuideSection.tsx +++ b/solution/site/src/sections/LocalGuideSection.tsx @@ -1,145 +1,217 @@ -import {ArrowUpRight, Calendar, MapPin, Utensils} from 'lucide-react'; +import {useState} from 'react'; +import {ArrowUpRight, MapPin, Utensils} from 'lucide-react'; import type {LocalPlace} from '@o2o/shared'; import {useSite} from '@/lib/site-context'; import {formatKoreanDate, naverSearchUrl} from '@/lib/format'; +import {sectionName} from '@/lib/derive'; /** - * 주변 정보. + * 주변 안내. * - * "○○ 근처 숙소" 같은 지역 질의에 걸리려면 지역 고유명사가 본문에 있어야 한다. + * ★ 마크업은 목업(/s/stay 의 `<section id="guide">`)에서 그대로 옮겼다. + * ★ "○○ 근처 숙소" 같은 지역 질의에 걸리려면 지역 고유명사가 본문에 있어야 한다. * ★ 우리가 남의 가게 URL 을 지어내지 않는다 — 검색 링크로만 보낸다. * 틀린 링크는 사용자를 엉뚱한 데로 보내고, 그 책임은 이 홈페이지가 진다. + * ★ 거리 탭은 **좌표가 있을 때만** 뜬다. 거리 없이 "걸어서 5분"을 만들면 손님이 헛걸음한다. */ + +/** 분속 80m 로 환산한 도보 시간. 직선거리 기준이라 실제 길은 이보다 길다. */ +const WALK_M_PER_MIN = 80; + +function walkMinutes(place: LocalPlace): number | null { + return typeof place.distanceMeters === 'number' + ? Math.max(1, Math.round(place.distanceMeters / WALK_M_PER_MIN)) + : null; +} + +function distanceLabel(place: LocalPlace): string | null { + const meters = place.distanceMeters; + if (typeof meters !== 'number') return place.distanceText ?? null; + return meters >= 1000 ? `${(meters / 1000).toFixed(1)}km` : `${Math.round(meters)}m`; +} + +const BANDS = [ + {key: 'all', label: '전체', test: () => true}, + {key: 'w5', label: '걸어서 5분 이내', test: (m: number | null) => m !== null && m <= 5}, + {key: 'w10', label: '걸어서 10분 이내', test: (m: number | null) => m !== null && m <= 10}, + {key: 'far', label: '걸어서 10분 이상', test: (m: number | null) => m !== null && m > 10}, +] as const; + +function PlaceRail({items, label, on}: {items: LocalPlace[]; label: string; on: (p: LocalPlace) => boolean}) { + if (items.length === 0) return null; + return ( + <div className="relative"> + <div + className="flex snap-x snap-mandatory gap-3 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" + role="group" + aria-roledescription="캐러셀" + aria-label={label} + tabIndex={0} + > + {items.map((place) => { + const minutes = walkMinutes(place); + const distance = distanceLabel(place); + return ( + <div + key={place.name} + hidden={!on(place)} + className="min-w-0 shrink-0 grow-0 basis-[76%] snap-start sm:basis-1/3 lg:basis-1/4" + aria-roledescription="슬라이드" + > + <a + href={naverSearchUrl(place.searchQuery)} + target="_blank" + rel="noopener noreferrer nofollow" + className="panel group flex h-full flex-col overflow-hidden transition-opacity hover:opacity-85" + > + {place.imageUrl && ( + <span className="relative block aspect-4/3 overflow-hidden"> + <img + src={place.imageUrl} + alt={`${place.name} 사진`} + loading="lazy" + decoding="async" + className="size-full object-cover transition-transform duration-500 group-hover:scale-105" + /> + {minutes !== null && ( + <span + className="absolute bottom-2 left-2 rounded-md px-2 py-0.5 text-[length:var(--fs-xs)] font-bold" + style={{ + backgroundColor: + 'color-mix(in srgb, var(--tpl-inverse, #1c1917) 78%, transparent)', + color: 'var(--tpl-bg, #fff)', + }} + > + 도보 약 {minutes}분 + {distance && <span className="ml-1 font-normal opacity-70">{distance}</span>} + </span> + )} + </span> + )} + <span className="flex flex-1 flex-col gap-1 p-3.5"> + <span className="text-[length:var(--fs-sm)] font-bold">{place.name}</span> + {place.description && ( + <span className="text-muted line-clamp-3 text-[length:var(--fs-xs)] leading-relaxed"> + {place.description} + </span> + )} + <span className="text-muted mt-auto flex items-center gap-0.5 pt-1.5 text-[length:var(--fs-xs)] opacity-70 transition-opacity group-hover:opacity-100"> + <span>검색으로 열기</span> + <ArrowUpRight className="size-3" aria-hidden="true" /> + </span> + </span> + </a> + </div> + ); + })} + </div> + </div> + ); +} + export function LocalGuideSection() { const payload = useSite(); const {local} = payload; - const hasAnything = - local.restaurants.length > 0 || local.attractions.length > 0 || local.festivals.length > 0; + const [band, setBand] = useState<string>('all'); + + const hasAnything = local.restaurants.length > 0 || local.attractions.length > 0; if (!hasAnything) return null; + const all = [...local.restaurants, ...local.attractions]; + const hasDistance = all.some((place) => typeof place.distanceMeters === 'number'); + const active = BANDS.find((b) => b.key === band) ?? BANDS[0]; + // ★ 거르지 않고 **접는다**(hidden). 걸러 버리면 접힌 가게가 HTML 에서 사라져 + // 검색·AI 가 못 읽는다 — 이 섹션을 둔 이유가 지역 고유명사를 본문에 두는 것이다. + const isOn = (place: LocalPlace) => band === 'all' || active.test(walkMinutes(place)); + + const region = payload.place.addressLocality ?? payload.place.addressRegion ?? ''; + const groups = [ + {title: '주변 맛집', Icon: Utensils, items: local.restaurants}, + {title: '주변 명소', Icon: MapPin, items: local.attractions}, + ].filter((group) => group.items.length > 0); + return ( <section id="guide" aria-labelledby="guide-heading" - className="w-full border-b border-black/8 py-12 sm:py-20" - style={{backgroundColor: 'var(--color-surface-alt)'}} + className="border-line paper w-full border-b" + style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)', paddingBlock: 'var(--section-space)'}} > - <div className="shell max-w-[880px]"> - <div className="mb-8 flex flex-col justify-between gap-2 border-b border-black/8 pb-4 sm:flex-row sm:items-baseline"> - <div> - <h2 id="guide-heading" className="serif text-2xl font-bold tracking-tight sm:text-3xl"> - {'주변 안내'} - </h2> - <p className="mt-1 text-xs opacity-60 sm:text-sm"> - {payload.place.addressLocality ?? '주변'} 지역의 맛집 · 명소 · 행사 안내입니다. - </p> - </div> - {local.syncedAt && ( - <p className="self-start text-[11px] opacity-50 sm:self-auto"> - {formatKoreanDate(local.syncedAt)} 갱신 - </p> - )} + <div className="shell"> + <div> + <header className="mb-6 sm:mb-8"> + <div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6"> + <div className="min-w-0"> + <h2 id="guide-heading" className="h2 flex items-center gap-2.5"> + <i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" /> + <span className="min-w-0">{sectionName(payload, 'local', '주변 안내')}</span> + </h2> + <p className="text-muted measure mt-2 pl-[calc(3px+0.625rem)] text-[length:var(--fs-sm)]"> + {region} 지역의 맛집 · 명소 안내입니다. + </p> + </div> + {local.syncedAt && ( + <div className="shrink-0"> + <p className="text-muted text-[length:var(--fs-xs)]"> + {formatKoreanDate(local.syncedAt)} 갱신 + </p> + </div> + )} + </div> + </header> </div> - {local.restaurants.length > 0 && ( - <PlaceList title="주변 맛집" icon={Utensils} places={local.restaurants} /> - )} - {local.attractions.length > 0 && ( - <PlaceList title="주변 명소" icon={MapPin} places={local.attractions} /> - )} - - {local.festivals.length > 0 && ( - <div className="space-y-4"> - <h3 className="serif flex items-center gap-2 text-base font-bold sm:text-lg"> - <Calendar className="size-4 opacity-50" /> - <span>지역 행사</span> - </h3> - - <ul - className="divide-y divide-black/5 overflow-hidden rounded-xl border border-black/8" - style={{backgroundColor: 'var(--color-surface)'}} - > - {local.festivals.map((festival) => ( - <li key={festival.name} className="flex items-center justify-between gap-3 p-4"> - <div className="min-w-0 space-y-0.5"> - <div className="flex items-center gap-2"> - <span className="rounded bg-black/5 px-1.5 py-0.5 text-[11px] font-bold"> - {festival.month} - </span> - <h4 className="truncate text-xs font-bold sm:text-sm">{festival.name}</h4> - </div> - <p className="text-xs opacity-60"> - {festival.description ?? festival.period} - </p> - </div> - - <a - href={festival.officialUrl ?? naverSearchUrl(festival.searchQuery)} - target="_blank" - rel="noopener noreferrer nofollow" - className="shrink-0 rounded-lg border border-black/10 px-3 py-1.5 text-xs font-medium transition-colors hover:bg-black/5" - > - 자세히 - </a> - </li> - ))} - </ul> + {hasDistance && ( + <div className="mb-6 flex flex-wrap gap-1.5" role="tablist" aria-label="거리"> + {BANDS.map((b) => { + const count = + b.key === 'all' ? all.length : all.filter((p) => b.test(walkMinutes(p))).length; + if (count === 0) return null; + const on = b.key === band; + return ( + <button + key={b.key} + type="button" + role="tab" + aria-selected={on} + onClick={() => setBand(b.key)} + className="border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80" + style={ + on + ? { + backgroundColor: 'var(--color-brand)', + color: 'var(--tpl-bg, #fff)', + borderColor: 'transparent', + } + : undefined + } + > + {b.label} <span className="tabular-nums opacity-70">{count}</span> + </button> + ); + })} </div> )} + + <div className="space-y-10"> + {groups.map((group) => ( + <div key={group.title}> + <h3 className="border-line mb-4 flex items-center gap-2 border-b pb-2 text-[length:var(--fs-sm)] font-bold"> + <group.Icon className="size-4" aria-hidden="true" /> + <span>{group.title}</span> + </h3> + <PlaceRail items={group.items} label={group.title} on={isOn} /> + </div> + ))} + </div> + + {hasDistance && ( + <p className="text-muted measure mt-5 text-[length:var(--fs-xs)]"> + 도보 시간은 숙소에서 잰 직선거리를 분속 {WALK_M_PER_MIN}m 로 환산한 값입니다. 실제로 걷는 + 길은 이보다 깁니다. + </p> + )} </div> </section> ); } - -function PlaceList({ - title, - icon: Icon, - places, -}: { - title: string; - icon: typeof MapPin; - places: LocalPlace[]; -}) { - return ( - <div className="mb-8 space-y-4"> - <h3 className="serif flex items-center gap-2 text-base font-bold sm:text-lg"> - <Icon className="size-4 opacity-50" /> - <span>{title}</span> - </h3> - - <ul - className="divide-y divide-black/5 overflow-hidden rounded-xl border border-black/8" - style={{backgroundColor: 'var(--color-surface)'}} - > - {places.map((place) => ( - <li key={place.name}> - <a - href={naverSearchUrl(place.searchQuery)} - target="_blank" - rel="noopener noreferrer nofollow" - className="group flex items-center justify-between gap-3 p-4 transition-colors hover:bg-black/[0.02]" - > - <div className="min-w-0 space-y-0.5 pr-3"> - <div className="flex items-center gap-2"> - <span className="truncate text-xs font-bold sm:text-sm">{place.name}</span> - {(place.distanceText ?? place.durationText) && ( - <span className="shrink-0 font-mono text-[10px] opacity-50"> - {place.distanceText ?? place.durationText} - </span> - )} - </div> - {place.description && ( - <p className="truncate text-xs opacity-60">{place.description}</p> - )} - </div> - <span className="flex shrink-0 items-center gap-0.5 text-xs opacity-40 transition-opacity group-hover:opacity-80"> - <span>검색</span> - <ArrowUpRight className="size-3.5" /> - </span> - </a> - </li> - ))} - </ul> - </div> - ); -} diff --git a/solution/site/src/sections/LocationSection.tsx b/solution/site/src/sections/LocationSection.tsx index 0dd97a2..bf47cf2 100644 --- a/solution/site/src/sections/LocationSection.tsx +++ b/solution/site/src/sections/LocationSection.tsx @@ -1,8 +1,18 @@ import {useState} from 'react'; -import {Car, Check, Copy, MapPin, Navigation} from 'lucide-react'; +import {Car, Check, Copy, Navigation} from 'lucide-react'; import {useSite} from '@/lib/site-context'; import {kakaoDirectionsUrl, naverDirectionsUrl, osmEmbedUrl} from '@/lib/format'; +import {sectionName} from '@/lib/derive'; +/** + * 오시는 길. + * + * ★ 마크업은 목업(/s/stay 의 `<section id="location">`)에서 그대로 옮겼다. + * 지도 7 : 주소·길찾기 5 의 가로 배치다. 지도를 위에 눕히면 주소가 접힌 화면 밖으로 밀린다. + * ★ 길찾기 버튼 색은 각 서비스의 브랜드색을 그대로 쓴다(네이버 #03C75A · 카카오 #FEE500 · + * 티맵 #0064FF). 템플릿 색으로 칠하면 어느 앱으로 가는지 손님이 못 알아본다. + * ★ 티맵은 앱 딥링크라 손가락 화면에서만 보인다(`only-touch`). + */ export function LocationSection() { const payload = useSite(); const {place, routes} = payload; @@ -15,6 +25,7 @@ export function LocationSection() { // 버튼은 검색으로 떨어진다(format.ts) — 틀린 핀을 찍느니 안 찍는다. const {latitude: lat, longitude: lng} = place; const hasGeo = lat != null && lng != null; + const q = encodeURIComponent(place.name); const handleCopy = async () => { try { @@ -30,136 +41,120 @@ export function LocationSection() { <section id="location" aria-labelledby="location-heading" - className="w-full border-b border-black/8 py-16 sm:py-24" + className="border-line paper w-full border-b" + style={{backgroundColor: 'var(--tpl-surface, #ffffff)', paddingBlock: 'var(--section-space)'}} > <div className="shell"> - <p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50"> - <MapPin className="size-4" /> - <span>Location</span> - </p> + <div> + <header className="mb-6 sm:mb-8"> + <div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6"> + <div className="min-w-0"> + <h2 id="location-heading" className="h2 flex items-center gap-2.5"> + <i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" /> + <span className="min-w-0">{sectionName(payload, 'map', '오시는 길')}</span> + </h2> + <p className="text-muted measure mt-2 pl-[calc(3px+0.625rem)] text-[length:var(--fs-sm)]"> + 주소와 주요 거점까지의 이동 시간을 안내합니다. + </p> + </div> + </div> + </header> + </div> - <h2 id="location-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl"> - {'오시는 길'} - </h2> - <p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm"> - 주소와 주요 거점까지의 이동 시간을 안내합니다. - </p> + <div className="grid gap-6 lg:grid-cols-12 lg:gap-8"> + {hasGeo && ( + <div className="border-line tpl-border overflow-hidden rounded-xl border lg:col-span-7"> + {/* ★ loading="lazy" — 지도는 접힌 화면 아래에 있는 경우가 많고, 첫 화면 속도를 + 남의 서버 응답에 맡기지 않는다. title 은 스크린리더가 읽을 유일한 설명이다. */} + <iframe + title={`${place.name} 위치 지도`} + src={osmEmbedUrl(lat, lng)} + loading="lazy" + referrerPolicy="no-referrer-when-downgrade" + className="block aspect-4/3 w-full border-0 sm:aspect-video lg:aspect-auto lg:h-full lg:min-h-[24rem]" + /> + </div> + )} - {hasGeo && ( - <div className="mb-6 overflow-hidden rounded-2xl border border-black/8"> - {/* ★ loading="lazy" — 지도는 접힌 화면 아래에 있는 경우가 많고, 첫 화면 속도를 - 남의 서버 응답에 맡기지 않는다. title 은 스크린리더가 읽을 유일한 설명이다. */} - <iframe - title={`${place.name} 위치 지도`} - src={osmEmbedUrl(lat, lng)} - loading="lazy" - referrerPolicy="no-referrer-when-downgrade" - className="block aspect-[16/9] w-full border-0" - /> - </div> - )} - - <div - className="mb-8 rounded-2xl border border-black/8 p-5 sm:p-6" - style={{backgroundColor: 'var(--color-surface-alt)'}} - > - <div className="flex flex-col justify-between gap-4 md:flex-row md:items-center"> - <div className="space-y-1"> - <p className="text-[11px] font-bold uppercase tracking-wide opacity-50">주소</p> + <div className={hasGeo ? 'lg:col-span-5' : 'lg:col-span-12'}> + <div className="panel p-5 sm:p-6"> + <p className="label mb-2">주소</p> <div className="flex flex-wrap items-center gap-2"> {/* address 태그로 감싼다 — 기계가 "이게 이 업소의 주소"임을 안다. */} - <address className="text-base font-bold not-italic sm:text-lg">{address}</address> + <address className="text-[length:var(--fs-lead)] font-bold not-italic">{address}</address> <button type="button" onClick={handleCopy} title="주소 복사" - className="inline-flex items-center gap-1 rounded-lg border border-black/10 px-2.5 py-1 text-xs font-medium transition-colors hover:bg-black/5" - style={{backgroundColor: 'var(--color-surface)'}} + className="border-line tpl-border inline-flex items-center gap-1 rounded-md border bg-current/8 px-2.5 py-1.5 text-[length:var(--fs-xs)] font-medium transition-opacity hover:opacity-70" > {copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />} <span>{copied ? '복사완료' : '복사'}</span> </button> </div> {place.phone && ( - <p className="text-xs opacity-60"> - 문의: <a href={`tel:${place.phone}`} className="underline-offset-2 hover:underline">{place.phone}</a> + <p className="text-muted mt-2 text-[length:var(--fs-sm)]"> + 문의:{' '} + <a href={`tel:${place.phone}`} className="underline-offset-2 hover:underline"> + {place.phone} + </a> </p> )} - </div> - <div className="flex items-center gap-2"> - <a - href={naverDirectionsUrl(place.name, lat, lng)} - target="_blank" - rel="noopener noreferrer nofollow" - className="flex items-center gap-1.5 rounded-xl bg-emerald-600 px-4 py-2.5 text-xs font-semibold text-white transition-opacity hover:opacity-90" - > - <Navigation className="size-3.5" /> - <span>네이버 길찾기</span> - </a> - <a - href={kakaoDirectionsUrl(place.name, lat, lng)} - target="_blank" - rel="noopener noreferrer nofollow" - className="flex items-center gap-1.5 rounded-xl bg-amber-400 px-4 py-2.5 text-xs font-semibold text-zinc-950 transition-opacity hover:opacity-90" - > - <Navigation className="size-3.5" /> - <span>카카오 길찾기</span> - </a> - </div> - </div> - </div> - - {routes.length > 0 && ( - <div> - <h3 className="mb-3 flex items-center gap-2 text-sm font-bold sm:text-base"> - <Car className="size-4 opacity-60" /> - <span>주요 거점 소요시간</span> - </h3> - - <div - className="overflow-hidden rounded-2xl border border-black/8" - style={{backgroundColor: 'var(--color-surface)'}} - > - <div className="overflow-x-auto"> - <table className="w-full text-left text-xs sm:text-sm"> - <caption className="sr-only"> - {payload.place.name}에서 주요 거점까지의 이동 시간 - </caption> - <thead - className="border-b border-black/8 font-semibold" - style={{backgroundColor: 'var(--color-surface-alt)'}} + <div className="mt-5 flex flex-wrap gap-2.5"> + <a + href={naverDirectionsUrl(place.name, lat, lng)} + target="_blank" + rel="noopener noreferrer nofollow" + className="tap flex flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#03C75A] px-4 text-[length:var(--fs-sm)] font-bold text-white transition-opacity hover:opacity-90" + > + <Navigation className="size-3.5" aria-hidden="true" /> + <span>네이버 길찾기</span> + </a> + <a + href={kakaoDirectionsUrl(place.name, lat, lng)} + target="_blank" + rel="noopener noreferrer nofollow" + className="tap flex flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#FEE500] px-4 text-[length:var(--fs-sm)] font-bold text-[#191600] transition-opacity hover:opacity-90" + > + <Navigation className="size-3.5" aria-hidden="true" /> + <span>카카오 길찾기</span> + </a> + {hasGeo && ( + <a + href={`tmap://route?goalname=${q}&goalx=${lng}&goaly=${lat}&rGoName=${q}&rGoX=${lng}&rGoY=${lat}`} + className="tap only-touch flex-1 basis-40 items-center justify-center gap-2 whitespace-nowrap rounded-lg bg-[#0064FF] px-4 text-[length:var(--fs-sm)] font-bold text-white transition-opacity hover:opacity-90" > - <tr> - <th scope="col" className="p-3.5 sm:p-4">목적지</th> - <th scope="col" className="p-3.5 sm:p-4">차량</th> - <th scope="col" className="p-3.5 sm:p-4">도보 / 대중교통</th> - <th scope="col" className="hidden p-3.5 sm:table-cell sm:p-4">거리</th> - <th scope="col" className="hidden p-3.5 md:table-cell sm:p-4">비고</th> - </tr> - </thead> - <tbody className="divide-y divide-black/5"> - {routes.map((route) => ( - <tr key={route.destination}> - <th scope="row" className="p-3.5 text-left font-bold sm:p-4"> - {route.destination} - </th> - <td className="p-3.5 font-semibold sm:p-4">{route.byCar ?? '—'}</td> - <td className="p-3.5 opacity-70 sm:p-4">{route.byTransit ?? '—'}</td> - <td className="hidden p-3.5 font-mono opacity-50 sm:table-cell sm:p-4"> - {route.distanceText ?? '—'} - </td> - <td className="hidden p-3.5 text-xs opacity-50 md:table-cell sm:p-4"> - {route.note ?? ''} - </td> - </tr> - ))} - </tbody> - </table> + <Navigation className="size-3.5" aria-hidden="true" /> + <span>티맵 길찾기</span> + </a> + )} </div> </div> + + {routes.length > 0 && ( + <div className="mt-6"> + <h3 className="mb-3 flex items-center gap-2 text-[length:var(--fs-sm)] font-bold"> + <Car className="size-4 opacity-60" aria-hidden="true" /> + <span>주요 거점 소요시간</span> + </h3> + <dl className="divide-line border-line divide-y border-t"> + {routes.map((route) => ( + <div + key={route.destination} + className="flex items-baseline justify-between gap-4 py-2.5 text-[length:var(--fs-sm)]" + > + <dt className="text-muted shrink-0">{route.destination}</dt> + <dd className="text-right font-semibold"> + {[route.byCar, route.byTransit].filter(Boolean).join(' · ') || '—'} + </dd> + </div> + ))} + </dl> + </div> + )} </div> - )} + </div> </div> </section> ); diff --git a/solution/site/src/sections/RulesSection.tsx b/solution/site/src/sections/RulesSection.tsx index 1e819ca..2cc5ab3 100644 --- a/solution/site/src/sections/RulesSection.tsx +++ b/solution/site/src/sections/RulesSection.tsx @@ -26,7 +26,7 @@ export function RulesSection() { <section id="rules" aria-labelledby="rules-heading" - className="w-full border-b border-black/8 py-16 sm:py-24" + className="paper border-line w-full border-b" > <div className="shell"> <p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50"> @@ -34,8 +34,9 @@ export function RulesSection() { <span>House Rules</span> </p> - <h2 id="rules-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl"> - {sectionName(payload, 'rules', '이용 규정')} + <h2 id="rules-heading" className="h2 flex items-center gap-2.5"> + <i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" /> + <span className="min-w-0">{sectionName(payload, 'rules', '이용 규정')}</span> </h2> <p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm"> 예약 전에 꼭 확인해 주세요. 아래 내용은 모두 사업자가 확인한 규정입니다. @@ -47,7 +48,7 @@ export function RulesSection() { <div key={row.label} className="rounded-2xl border border-black/8 p-4 sm:p-5" - style={{backgroundColor: 'var(--color-surface-alt)'}} + style={{backgroundColor: 'var(--color-surface-alt)', paddingBlock: 'var(--section-space)'}} > <dt className="mb-1 text-xs font-bold sm:text-sm">{row.label}</dt> <dd className="text-xs leading-relaxed opacity-80 sm:text-sm">{row.value}</dd> diff --git a/solution/site/src/sections/SectionShell.tsx b/solution/site/src/sections/SectionShell.tsx new file mode 100644 index 0000000..4989ed2 --- /dev/null +++ b/solution/site/src/sections/SectionShell.tsx @@ -0,0 +1,68 @@ +/** + * 섹션 한 벌 — 바탕·여백·머리말. + * + * ★ 왜 공통으로 빼나 + * 섹션마다 `py-24`, `text-2xl`, `font-bold` 를 손으로 적고 있었다. 그래서 템플릿이 + * `sectionSpace: 4rem` 이나 `headingWeight: 400`(간판체)을 줘도 화면은 그대로였다 — + * 사장님이 레트로를 골라도 제목만 가짜 볼드로 굵어졌고, 여백은 언제나 96px 이었다. + * 여기 한 곳에서 토큰을 읽으면 템플릿이 실제로 화면을 바꾼다. + * + * ★ 제목 앞 세로 막대는 장식이 아니라 위계다. 같은 크기의 글자가 페이지에 여럿일 때 + * "여기서 새 섹션이 시작한다"를 알려주는 유일한 표시다. + */ +import type {ReactNode} from 'react'; + +export function SectionHeading({id, name, subtitle, bar = true, right}: { + id: string; + name: string; + subtitle?: string; + /** 제목 앞 세로 막대. 아이템 섹션처럼 자체 머리말이 있는 곳은 끈다. */ + bar?: boolean; + /** 제목 오른쪽에 붙는 것(갱신일·바깥 링크). */ + right?: ReactNode; +}) { + return ( + <header className="mb-6 sm:mb-8"> + <div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6"> + <div className="min-w-0"> + <h2 id={`${id}-heading`} className={bar ? 'h2 flex items-center gap-2.5' : 'h2'}> + {bar && <i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />} + <span className="min-w-0">{name}</span> + </h2> + {subtitle && ( + <p + className={`text-muted measure mt-2 text-[length:var(--fs-sm)]${bar ? ' pl-[calc(3px+0.625rem)]' : ''}`} + > + {subtitle} + </p> + )} + </div> + {right} + </div> + </header> + ); +} + +export function SectionShell({id, children, tone = 'base', border = true, className}: { + id: string; + children: ReactNode; + /** base = 페이지 바탕, alt = 한 단계 다른 면(섹션이 이어질 때 경계를 만든다). */ + tone?: 'base' | 'alt'; + border?: boolean; + className?: string; +}) { + return ( + <section + id={id} + aria-labelledby={`${id}-heading`} + className={`paper w-full${border ? ' border-line border-b' : ''}${className ? ` ${className}` : ''}`} + style={{ + backgroundColor: tone === 'alt' ? 'var(--tpl-surface-alt, #fafafa)' : 'var(--tpl-surface, #ffffff)', + color: 'var(--tpl-text, #09090b)', + paddingBlock: 'var(--section-space)', + }} + > + <div className="shell">{children}</div> + </section> + ); +} diff --git a/solution/site/src/sections/SpaceSection.tsx b/solution/site/src/sections/SpaceSection.tsx index a14764b..f2495e8 100644 --- a/solution/site/src/sections/SpaceSection.tsx +++ b/solution/site/src/sections/SpaceSection.tsx @@ -28,7 +28,7 @@ export function SpaceSection() { <section id="space" aria-labelledby="space-heading" - className="w-full border-b border-black/8 py-16 sm:py-24" + className="paper border-line w-full border-b" > <div className="shell"> <p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50"> @@ -36,8 +36,9 @@ export function SpaceSection() { <span>Space</span> </p> - <h2 id="space-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl"> - {sectionName(payload, 'space', '공간 안내')} + <h2 id="space-heading" className="h2 flex items-center gap-2.5"> + <i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" /> + <span className="min-w-0">{sectionName(payload, 'space', '공간 안내')}</span> </h2> <p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm"> 좌석과 이용 환경 안내입니다. @@ -48,7 +49,7 @@ export function SpaceSection() { <div key={row.label} className="rounded-2xl border border-black/8 p-4 sm:p-5" - style={{backgroundColor: 'var(--color-surface-alt)'}} + style={{backgroundColor: 'var(--color-surface-alt)', paddingBlock: 'var(--section-space)'}} > <dt className="mb-1 text-xs font-medium opacity-60">{row.label}</dt> <dd className="text-sm font-bold sm:text-base">{row.value}</dd> diff --git a/solution/site/src/sections/StorySection.tsx b/solution/site/src/sections/StorySection.tsx new file mode 100644 index 0000000..f77e7b9 --- /dev/null +++ b/solution/site/src/sections/StorySection.tsx @@ -0,0 +1,101 @@ +/** + * 지역 이야기 — 아이템 섹션들을 탭 하나로 묶는 머리. + * + * ★ 왜 묶나 — 가요·인물·연표·엽서·퀴즈가 각자 큰 섹션으로 서면 페이지가 다섯 배로 길어지고, + * 손님은 객실 정보에 닿기 전에 스크롤을 포기한다. 이야기는 '읽고 싶은 사람만' 여는 자리다. + * ★ **탭을 눌러 접는 것은 화면뿐이다.** 각 아이템 섹션은 그대로 HTML 에 다 구워진다 — + * 이 사이트의 존재 이유가 인용이라, 접힌 쪽이 HTML 에 없으면 만든 의미가 없다. + */ +import {useState} from 'react'; +import type {ReactElement} from 'react'; +import {useSite} from '@/lib/site-context'; +import {isSectionEnabled, sectionItems, sectionName} from '@/lib/derive'; +import {ITEM_SECTIONS} from './items'; + +/** 이야기 탭에 들어가는 아이템. 순서가 곧 탭 순서다. */ +const STORY_KINDS = ['songs', 'people', 'chronicle', 'literature', 'postcard', 'quiz', 'daily'] as const; + +const FALLBACK_LABEL: Record<string, string> = { + songs: '가요 다방', + people: '인물 열전', + chronicle: '시간의 골목', + literature: '문학 산책', + postcard: '오늘의 엽서', + quiz: '뒤집어 보는 질문', + daily: '오늘의 일력', +}; + +export function StorySection(): ReactElement | null { + const payload = useSite(); + const region = payload.place.addressLocality ?? payload.place.addressRegion ?? ''; + + // 켜져 있고 실제로 항목이 있는 것만 탭이 된다 — 빈 탭을 누르게 하지 않는다. + const tabs = STORY_KINDS.filter( + (kind) => isSectionEnabled(payload, kind) && sectionItems(payload, kind).items.length > 0, + ); + const [active, setActive] = useState<string>(tabs[0] ?? ''); + + if (tabs.length === 0) return null; + + return ( + <> + <section + id="story" + aria-labelledby="story-heading" + className="paper w-full" + style={{ + backgroundColor: 'var(--tpl-surface, #fafafa)', + color: 'var(--tpl-text, #09090b)', + paddingTop: 'var(--section-space)', + paddingBottom: 'calc(var(--section-space) * 0.55)', + }} + > + <div className="shell"> + <h2 id="story-heading" className="h2"> + {region} 이야기 + </h2> + <p className="measure mt-3 text-[length:var(--fs-sm)] opacity-70"> + 이 도시를 {tabs.length} 갈래로 봅니다. 하나씩 골라 보세요. + </p> + <div className="mt-6 flex flex-wrap gap-1.5" role="tablist" aria-label={`${region} 이야기`}> + {tabs.map((kind) => { + const on = kind === active; + return ( + <button + key={kind} + type="button" + role="tab" + aria-selected={on} + aria-controls={kind} + onClick={() => setActive(kind)} + className="rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80" + style={ + on + ? { + backgroundColor: 'var(--tpl-text, #09090b)', + color: 'var(--tpl-bg, #ffffff)', + borderColor: 'var(--tpl-text, #09090b)', + } + : {borderColor: 'var(--tpl-border, #d6d3d1)'} + } + > + {sectionName(payload, kind, FALLBACK_LABEL[kind] ?? kind)} + </button> + ); + })} + </div> + </div> + </section> + + {tabs.map((kind) => { + const Component = ITEM_SECTIONS[kind]; + if (!Component) return null; + return ( + <div key={kind} hidden={kind !== active}> + <Component /> + </div> + ); + })} + </> + ); +} diff --git a/solution/site/src/sections/UnitsSection.tsx b/solution/site/src/sections/UnitsSection.tsx index 88b81ea..c54b927 100644 --- a/solution/site/src/sections/UnitsSection.tsx +++ b/solution/site/src/sections/UnitsSection.tsx @@ -1,18 +1,92 @@ -import {ChevronDown, Phone} from 'lucide-react'; +import {useRef, useState} from 'react'; +import {ChevronDown, ChevronLeft, ChevronRight} from 'lucide-react'; +import type {MediaItem} from '@o2o/shared'; import {useSite} from '@/lib/site-context'; import {sectionName, unitSpec, unitViews} from '@/lib/derive'; /** * 객실 · 메뉴 · 프로그램 — 업종에 따라 이름만 달라진다. * - * ★ 예전에는 카드가 상세 **페이지**로 넘어갔다. 사이트를 한 장으로 합치면서 - * 그 페이지의 내용(이용 정보 표 · 사진 전체 · 전화 문의)을 카드 안으로 들여왔다. - * 빼고 합쳤으면 확인된 fact 가 화면에서 사라지고, JSON-LD 에는 남아 있어 - * 발행 게이트(화면 ↔ 구조화 데이터 대조)에 걸린다. - * + * ★ 마크업은 목업(/s/stay 의 `<section id="units">`)에서 그대로 옮겼다. + * ★ 사진은 카드 안에서 가로로 민다. 격자로 늘어놓으면 카드가 세로로 길어져 + * 객실 두 개가 한 화면에 안 들어온다. * ★ 펼침은 <details> 다. 자바스크립트 없이 동작하고, 크롤러는 접힌 내용도 읽는다 — * 조건부 렌더로 감추면 HTML 에 아예 없어서 AI 가 못 본다. */ +function UnitPhotos({images, label}: {images: MediaItem[]; label: string}) { + const railRef = useRef<HTMLUListElement>(null); + const [index, setIndex] = useState(0); + + // ★ 스크롤 위치로 현재 장을 읽는다. 상태로만 밀면 손가락으로 민 것과 어긋난다. + const onScroll = () => { + const rail = railRef.current; + if (!rail) return; + setIndex(Math.round(rail.scrollLeft / Math.max(1, rail.clientWidth))); + }; + const move = (delta: number) => { + const rail = railRef.current; + if (!rail) return; + rail.scrollTo({left: (index + delta) * rail.clientWidth, behavior: 'smooth'}); + }; + + return ( + <div className="relative"> + <ul + ref={railRef} + onScroll={onScroll} + aria-label={`${label} 사진`} + className="flex snap-x snap-mandatory overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" + > + {images.map((image) => ( + <li key={image.mediaId} className="w-full shrink-0 snap-center"> + <div className="relative aspect-16/10 overflow-hidden"> + <img + src={image.url} + alt={image.alt} + loading="lazy" + decoding="async" + className="size-full object-cover" + /> + </div> + </li> + ))} + </ul> + + {images.length > 1 && ( + <> + <button + type="button" + aria-label="이전 사진" + aria-hidden={index === 0} + tabIndex={index === 0 ? -1 : 0} + onClick={() => move(-1)} + className={`absolute top-1/2 z-10 flex size-8 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 left-2 ${ + index === 0 ? 'pointer-events-none opacity-0' : 'opacity-100' + }`} + > + <ChevronLeft className="size-4" aria-hidden="true" /> + </button> + <button + type="button" + aria-label="다음 사진" + aria-hidden={index >= images.length - 1} + tabIndex={index >= images.length - 1 ? -1 : 0} + onClick={() => move(1)} + className={`absolute top-1/2 z-10 flex size-8 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 right-2 ${ + index >= images.length - 1 ? 'pointer-events-none opacity-0' : 'opacity-100' + }`} + > + <ChevronRight className="size-4" aria-hidden="true" /> + </button> + <span className="pointer-events-none absolute bottom-2.5 right-2.5 rounded-full bg-black/55 px-2 py-0.5 text-[length:var(--fs-xs)] font-semibold tabular-nums text-white"> + {index + 1} / {images.length} + </span> + </> + )} + </div> + ); +} + export function UnitsSection() { const payload = useSite(); const spec = unitSpec(payload); @@ -24,130 +98,75 @@ export function UnitsSection() { <section id="units" aria-labelledby="units-heading" - className="w-full border-b border-black/8 py-16 sm:py-24" + className="border-line paper w-full border-b" + style={{backgroundColor: 'var(--tpl-surface, #ffffff)', paddingBlock: 'var(--section-space)'}} > <div className="shell"> - <div className="mb-10 flex flex-col justify-between gap-4 sm:flex-row sm:items-end"> - <div> - <p className="mb-2 text-xs font-semibold uppercase tracking-wider opacity-50"> - {spec.label} - </p> - {/* 섹션 id 는 업종마다 다르다(rooms · menu · programs) — spec.path 가 그 id 다. - 사장님이 이름을 안 바꿨으면 지금까지 쓰던 "객실 3개 안내" 꼴로 떨어진다. */} - <h2 id="units-heading" className="serif text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl"> - {sectionName(payload, spec.path, `${spec.label} ${units.length}개 안내`)} - </h2> - </div> + <div> + <header className="mb-6 sm:mb-8"> + <div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6"> + <div className="min-w-0"> + <h2 id="units-heading" className="h2 flex items-center gap-2.5"> + <i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" /> + <span className="min-w-0">{sectionName(payload, spec.path, `${spec.label} 안내`)}</span> + </h2> + </div> + </div> + </header> </div> - <ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:gap-8"> + {/* ★ items-start — 사진 없는 객실 카드가 옆 카드 높이를 따라가면 아래가 빈 칸으로 남는다. + 목업은 전 객실에 사진이 있어 이 문제가 없었지만, 공공 API 사진은 일부 객실에만 붙는다. */} + <ul className="grid grid-cols-1 items-start gap-5 sm:grid-cols-2 lg:gap-7"> {units.map((unit) => ( - <li - key={unit.unitId} - className="flex flex-col justify-between overflow-hidden rounded-2xl border border-black/8" - style={{backgroundColor: 'var(--color-surface-alt)'}} - > - <div> - {unit.images[0] && ( - <div className="relative aspect-16/10 overflow-hidden sm:aspect-16/9"> - <img - src={unit.images[0].url} - alt={unit.images[0].alt} - loading="lazy" - decoding="async" - className="size-full object-cover" - /> - {unit.priceText && ( - <span - className="absolute bottom-3 right-3 rounded-lg px-2.5 py-1 text-xs font-bold" - style={{ - backgroundColor: 'var(--color-surface)', - color: 'var(--color-ink)', - }} - > - {unit.priceText} - </span> - )} - </div> - )} + <li key={unit.unitId} className="panel flex flex-col overflow-hidden"> + <div className="flex flex-1 flex-col"> + {unit.images.length > 0 && <UnitPhotos images={unit.images} label={unit.name} />} - <div className="space-y-3 p-4 sm:space-y-4 sm:p-6"> - <h3 className="serif text-base font-bold leading-snug sm:text-xl"> - {unit.name} - </h3> + <div className="flex flex-1 flex-col gap-3 p-5"> + <h3 className="h3">{unit.name}</h3> {unit.chips.length > 0 && ( - <ul className="flex flex-wrap items-center gap-1.5 sm:gap-2"> + <ul className="flex flex-wrap items-center gap-1.5"> {unit.chips.map((chip) => ( - <li - key={chip.label} - className="rounded-lg border border-black/8 px-2.5 py-1 text-xs" - style={{backgroundColor: 'var(--color-surface)'}} - > - <span className="opacity-50">{chip.label} </span> - <span className="font-medium">{chip.value}</span> + <li key={chip.label}> + <span className="border-line tpl-border inline-flex items-center gap-1 rounded-md border bg-current/5 px-2.5 py-1 text-[length:var(--fs-xs)]"> + <span className="text-muted">{chip.label}</span> + <span className="font-medium">{chip.value}</span> + </span> </li> ))} </ul> )} {unit.intro && ( - <p className="text-xs leading-relaxed opacity-70">{unit.intro}</p> + <p className="text-[length:var(--fs-sm)] leading-relaxed opacity-75">{unit.intro}</p> )} </div> </div> - {(unit.rows.length > 0 || unit.images.length > 1) && ( - <details className="group border-t border-black/8"> - <summary className="flex cursor-pointer list-none items-center justify-between gap-2 p-4 text-xs font-semibold sm:p-6"> + {unit.rows.length > 0 && ( + <details className="group border-line border-t"> + <summary className="tap flex cursor-pointer list-none items-center justify-between gap-2 px-5 text-[length:var(--fs-sm)] font-semibold"> <span>{unit.name} 자세히</span> - <ChevronDown className="size-4 transition-transform group-open:rotate-180" /> + <ChevronDown + className="size-4 transition-transform group-open:rotate-180" + aria-hidden="true" + /> </summary> - <div className="space-y-5 px-4 pb-4 sm:px-6 sm:pb-6"> - {unit.rows.length > 0 && ( - <dl className="divide-y divide-black/5 border-t border-black/8"> - {unit.rows.map((row) => ( - <div - key={row.label} - className="flex items-start justify-between gap-4 py-2 text-xs" - > - <dt className="shrink-0 opacity-55">{row.label}</dt> - <dd className="text-right font-semibold">{row.value}</dd> - </div> - ))} - </dl> - )} - - {unit.images.length > 1 && ( - <ul className="grid grid-cols-2 gap-2"> - {unit.images.slice(1).map((image) => ( - <li - key={image.mediaId} - className="relative aspect-16/10 overflow-hidden rounded-xl" - > - <img - src={image.url} - alt={image.alt} - loading="lazy" - decoding="async" - className="size-full object-cover" - /> - </li> - ))} - </ul> - )} - - {payload.place.phone && ( - <a - href={`tel:${payload.place.phone}`} - className="flex w-full items-center justify-center gap-1.5 rounded-xl px-3 py-2.5 text-xs font-semibold text-white transition-opacity hover:opacity-90" - style={{backgroundColor: 'var(--color-brand)'}} - > - <Phone className="size-3.5" /> - <span>{payload.place.phone} 문의</span> - </a> - )} + <div className="space-y-5 px-5 pb-5"> + <dl className="divide-line border-line divide-y border-t"> + {unit.rows.map((row) => ( + <div + key={row.label} + className="flex items-baseline justify-between gap-4 py-2.5 text-[length:var(--fs-sm)]" + > + <dt className="text-muted shrink-0">{row.label}</dt> + <dd className="text-right font-semibold">{row.value}</dd> + </div> + ))} + </dl> </div> </details> )} diff --git a/solution/site/src/sections/WeatherSection.tsx b/solution/site/src/sections/WeatherSection.tsx index 8822f2a..a72f5fa 100644 --- a/solution/site/src/sections/WeatherSection.tsx +++ b/solution/site/src/sections/WeatherSection.tsx @@ -1,8 +1,25 @@ -import {Cloud} from 'lucide-react'; import {useSite} from '@/lib/site-context'; import {useLiveWeather} from '@/lib/use-live-weather'; +import {sectionName} from '@/lib/derive'; + +/** + * 오늘의 날씨 — 프리렌더 스냅샷으로 시작해 하이드레이션 후 최신값으로 갱신된다. + * + * ★ 마크업은 목업(/s/stay 의 `<section id="weather">`)에서 그대로 옮겼다. + * ★ 기온은 히어로 제목과 같은 크기(`--fs-display`)로 크게 둔다. 이 섹션에서 손님이 + * 찾는 값은 숫자 하나뿐이라, 그 숫자가 작으면 섹션을 둔 의미가 없다. + * ★ 정적 페이지는 몇 달 산다 — 관측 시각을 반드시 함께 적는다. 시각 없는 기온은 + * 언제 잰 것인지 알 수 없어 그 자체가 틀린 정보가 된다. + */ +function observedLabel(iso?: string): string | null { + if (!iso) return null; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return null; + return `${d.getMonth() + 1}월 ${d.getDate()}일 ${String(d.getHours()).padStart(2, '0')}:${String( + d.getMinutes(), + ).padStart(2, '0')} 관측`; +} -/** 프리렌더 스냅샷으로 시작해 하이드레이션 후 최신 캐시로 갱신되는 독립 날씨 섹션. */ export function WeatherSection() { const payload = useSite(); const weather = useLiveWeather({ @@ -13,33 +30,54 @@ export function WeatherSection() { }); if (!weather) return null; + const observed = observedLabel(weather.observedAt); return ( <section id="weather" aria-labelledby="weather-heading" - className="w-full border-b border-black/8 py-10 sm:py-14" - style={{backgroundColor: 'var(--color-surface-alt)'}} + className="border-line paper w-full border-b" + style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)', paddingBlock: 'var(--section-space)'}} > - <div className="shell max-w-[880px]"> - <h2 id="weather-heading" className="serif mb-4 text-xl font-bold tracking-tight sm:text-2xl"> - 오늘의 날씨 - </h2> - <div - className="rounded-xl border border-black/8 p-5 sm:p-6" - style={{backgroundColor: 'var(--color-surface)'}} - > - <div className="flex items-center gap-3"> - <Cloud className="size-6 opacity-50" aria-hidden="true" /> - <div className="flex items-baseline gap-2"> - <span className="text-2xl font-extrabold sm:text-3xl">{weather.temperature}°C</span> - <span className="text-xs font-semibold opacity-70">{weather.condition}</span> - {weather.stale && <span className="text-[10px] text-amber-700">최근 관측값</span>} + <div className="shell"> + <div> + <header className="mb-6 sm:mb-8"> + <div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6"> + <div className="min-w-0"> + <h2 id="weather-heading" className="h2 flex items-center gap-2.5"> + <i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" /> + <span className="min-w-0">{sectionName(payload, 'weather', '오늘의 날씨')}</span> + </h2> + </div> </div> + </header> + </div> + + <div className="panel relative overflow-hidden"> + {/* 하늘 — 날씨에 따라 결이 달라진다. 색을 박지 않고 토큰에서 유도한다. */} + <div className="w4-sky" data-mood={weather.condition} aria-hidden="true" /> + <div className="relative flex flex-col gap-5 p-6 sm:p-8"> + <div className="flex flex-wrap items-baseline gap-x-4 gap-y-1"> + <p className="flex items-baseline gap-2.5"> + <span + className="serif tabular-nums leading-none" + style={{fontSize: 'var(--fs-display)'}} + > + {weather.temperature} + </span> + <span className="text-[length:var(--fs-lead)] opacity-70">°C</span> + <span className="text-[length:var(--fs-lead)] font-semibold">{weather.condition}</span> + </p> + {observed && ( + <span className="text-muted text-[length:var(--fs-xs)] tabular-nums">{observed}</span> + )} + </div> + {weather.note && ( + <p className="measure serif text-[length:var(--fs-lead)] leading-loose opacity-85"> + {weather.note} + </p> + )} </div> - {weather.note && ( - <p className="mt-2 text-xs leading-relaxed opacity-70 sm:text-sm">{weather.note}</p> - )} </div> </div> </section> diff --git a/solution/site/src/sections/index.ts b/solution/site/src/sections/index.ts index a6ec6ed..ca0ac51 100644 --- a/solution/site/src/sections/index.ts +++ b/solution/site/src/sections/index.ts @@ -10,6 +10,8 @@ export {SpaceSection} from './SpaceSection'; export {InquirySection} from './InquirySection'; export {ExhibitionSection} from './ExhibitionSection'; export {LocalGuideSection} from './LocalGuideSection'; +export {FestivalSection} from './FestivalSection'; +export {StorySection} from './StorySection'; export {WeatherSection} from './WeatherSection'; export {GallerySection} from './GallerySection'; export {LocationSection} from './LocationSection'; diff --git a/solution/site/src/sections/items/ChronicleSection.tsx b/solution/site/src/sections/items/ChronicleSection.tsx index 66bc9dd..a5a5b85 100644 --- a/solution/site/src/sections/items/ChronicleSection.tsx +++ b/solution/site/src/sections/items/ChronicleSection.tsx @@ -24,7 +24,7 @@ export function ChronicleSection() { <ItemSection id="chronicle" name={parsed.title || sectionName(payload, 'chronicle', '시간의 골목')} - subtitle={parsed.subtitle} + subtitle={parsed.subtitle ?? '이 도시를 만든 해들'} count={`붉은 점은 도시의 성격이 바뀐 해입니다 · ${turning}개 / 전체 ${items.length}개`} > <Rail label="연표"> @@ -51,11 +51,23 @@ export function ChronicleSection() { }} /> </div> - <div className="space-y-1.5 pr-5"> - <h3 className="serif text-base font-bold">{item.title}</h3> - {item.summary && <p className="text-[13px] leading-relaxed opacity-80">{item.summary}</p>} - {item.place && <p className="text-[11px] opacity-60">지금 이 자리 · {item.place}</p>} - <SourceLine source={item.source} verified={item.verified} /> + <div className="flex gap-2.5 pr-5"> + {item.imageUrl && ( + <img + src={item.imageUrl} + alt={`${item.title} 사진`} + loading="lazy" + decoding="async" + className="size-14 shrink-0 border object-cover" + style={{borderColor: ITEM_BORDER}} + /> + )} + <div className="min-w-0 flex-1 space-y-1.5"> + <h3 className="serif text-base font-bold">{item.title}</h3> + {item.summary && <p className="text-[13px] leading-relaxed opacity-80">{item.summary}</p>} + {item.place && <p className="text-[11px] opacity-60">지금 이 자리 · {item.place}</p>} + <SourceLine source={item.source} verified={item.verified} /> + </div> </div> </article> ); diff --git a/solution/site/src/sections/items/EventSection.tsx b/solution/site/src/sections/items/EventSection.tsx new file mode 100644 index 0000000..69f31ba --- /dev/null +++ b/solution/site/src/sections/items/EventSection.tsx @@ -0,0 +1,54 @@ +/** + * 소식 — 사장님이 쓰는 공지·안내. + * + * ★ 지역 리서치 아이템과 성격이 다르다. 여기 글의 주인은 사장님이고 출처도 사장님이다 — + * 그래서 source 를 요구하지 않는다(SourceLine 대신 verified 배지만). + */ +import type {EventItem} from '@o2o/shared'; +import {useSite} from '@/lib/site-context'; +import {sectionItems, sectionName} from '@/lib/derive'; +import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ItemSection, tally} from './common'; + +export function EventSection() { + const payload = useSite(); + const parsed = sectionItems<EventItem>(payload, 'event'); + if (parsed.items.length === 0) return null; + + return ( + <ItemSection + id="event" + name={parsed.title || sectionName(payload, 'event', '소식')} + subtitle={parsed.subtitle ?? '미리 알아 두시면 좋은 것들'} + count={tally(parsed.items.length, parsed.unverified, '건')} + > + <ul className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3"> + {parsed.items.map((notice, index) => ( + <li + key={`${notice.title}-${index}`} + className="w4-paper flex flex-col border-2 p-5" + style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}} + > + {notice.kind && ( + <span + className="mb-2 self-start border px-2 py-0.5 text-[length:var(--fs-xs)] font-bold" + style={{borderColor: ITEM_ACCENT, color: ITEM_ACCENT}} + > + {notice.kind} + </span> + )} + <h3 className="serif text-[length:var(--fs-lead)] font-bold leading-snug">{notice.title}</h3> + {notice.summary && ( + <p className="measure mt-2 text-[length:var(--fs-sm)] opacity-80">{notice.summary}</p> + )} + {notice.body && ( + // 줄바꿈이 항목 구분이다. 사장님이 쓴 그대로 둔다. + <p className="mt-3 whitespace-pre-line text-[length:var(--fs-sm)] leading-relaxed opacity-75"> + {notice.body} + </p> + )} + </li> + ))} + </ul> + </ItemSection> + ); +} diff --git a/solution/site/src/sections/items/ItinerarySection.tsx b/solution/site/src/sections/items/ItinerarySection.tsx new file mode 100644 index 0000000..d6ffa30 --- /dev/null +++ b/solution/site/src/sections/items/ItinerarySection.tsx @@ -0,0 +1,210 @@ +/** + * 추천 일정 — 며칠 묵느냐로 갈린다. + * + * ★ 마크업은 목업(/s/stay 의 `<section id="itinerary">`)에서 그대로 옮겼다. + * 기간 탭 → 일정마다 날짜 카드를 가로로 밀고, 카드 안에 지도와 타임라인이 함께 있다. + * ★ 시각은 **계산한다.** 출발 시각과 머무는 분을 더해 나가므로, 출발을 당기면 하루가 + * 통째로 밀린다. 모델이 시각을 적어 내면 합이 안 맞는 표가 나온다. + * ★ 전 일정을 HTML 에 편다. 탭은 화면에서만 접는다 — 접힌 쪽이 HTML 에 없으면 + * 검색·AI 가 나머지 일정을 못 읽는다. + */ +import {useState} from 'react'; +import type {ItineraryDay, ItineraryItem, ItineraryStop} from '@o2o/shared'; +import {useSite} from '@/lib/site-context'; +import {sectionItems, sectionName} from '@/lib/derive'; +import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ItemSection, SourceLine, tally} from './common'; +import {TripMap, type TripPoint} from './TripMap'; + +/** 'HH:MM' → 분. 형식이 아니면 null(시각을 지어내지 않는다). */ +function toMinutes(time?: string): number | null { + const m = /^(\d{1,2}):(\d{2})$/.exec((time ?? '').trim()); + if (!m) return null; + return Number(m[1]) * 60 + Number(m[2]); +} +/** '3시간 40분' — 첫 칸 시작부터 마지막 칸 끝까지. */ +function spanLabel(rows: {from?: string; to?: string}[]): string | null { + const from = toMinutes(rows[0]?.from); + const to = toMinutes(rows[rows.length - 1]?.to); + if (from === null || to === null || to <= from) return null; + const total = to - from; + const h = Math.floor(total / 60); + const m = total % 60; + return [h ? `${h}시간` : '', m ? `${m}분` : ''].filter(Boolean).join(' '); +} + +function toClock(minutes: number): string { + const h = Math.floor(minutes / 60) % 24; + return `${String(h).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`; +} + +/** 정거장마다 시작·끝 시각을 계산한다. 출발 시각이 없으면 시각을 아예 안 그린다. */ +function schedule(day: ItineraryDay): {stop: ItineraryStop; from?: string; to?: string}[] { + let cursor = toMinutes(day.startTime); + return (day.stops ?? []).map((stop) => { + if (cursor === null) return {stop}; + const from = cursor; + const to = from + (stop.minutes ?? 0); + cursor = to; + return {stop, from: toClock(from), to: toClock(to)}; + }); +} + +export function ItinerarySection() { + const payload = useSite(); + const parsed = sectionItems<ItineraryItem>(payload, 'itinerary'); + const durations = Array.from( + new Set(parsed.items.map((item) => item.duration?.trim()).filter(Boolean) as string[]), + ); + const [active, setActive] = useState(durations[0] ?? ''); + + if (parsed.items.length === 0) return null; + + return ( + <ItemSection + id="itinerary" + name={parsed.title || sectionName(payload, 'itinerary', '추천 일정')} + subtitle={parsed.subtitle ?? '며칠 묵느냐에 따라 다르게 돕니다'} + count={`${tally(parsed.items.length, parsed.unverified, '개')} · 시각은 출발 시각과 머무는 시간으로 계산한 것입니다`} + > + {durations.length > 1 && ( + <div className="mb-6 flex flex-wrap gap-1.5" role="tablist" aria-label="묵는 기간"> + {durations.map((duration) => ( + <button + key={duration} + type="button" + role="tab" + aria-selected={duration === active} + onClick={() => setActive(duration)} + className="border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80" + style={ + duration === active + ? {backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)', borderColor: 'transparent'} + : undefined + } + > + {duration} + <span className="ml-1.5 font-normal opacity-60"> + {parsed.items.filter((i) => i.duration === duration).length} + </span> + </button> + ))} + </div> + )} + + <div className="space-y-10"> + {parsed.items.map((plan, planIndex) => ( + <div + key={`${plan.name}-${planIndex}`} + hidden={durations.length > 1 && plan.duration !== active} + className="space-y-3" + > + <div className="space-y-1"> + <h3 className="serif text-lg font-bold">{plan.name}</h3> + {plan.audience && <p className="text-[13px] opacity-60">{plan.audience}</p>} + </div> + + <div className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3"> + {(plan.days ?? []).map((day, dayIndex) => { + const rows = schedule(day); + const last = rows[rows.length - 1]; + const points: TripPoint[] = (day.stops ?? []) + .filter((s): s is ItineraryStop & {latitude: number; longitude: number} => + typeof s.latitude === 'number' && typeof s.longitude === 'number') + .map((s) => ({name: s.name, latitude: s.latitude, longitude: s.longitude, searchQuery: s.searchQuery})); + + return ( + <article + key={`${day.label}-${dayIndex}`} + className="w4-paper w-[320px] shrink-0 snap-center border" + style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}} + > + <div + className="flex items-center justify-between gap-2 border-b px-4 py-2.5" + style={{borderColor: ITEM_BORDER}} + > + <span + className="border px-2 py-0.5 text-[11px] font-bold" + style={{ + backgroundColor: 'var(--tpl-text, #09090b)', + color: 'var(--tpl-bg, #ffffff)', + borderColor: 'var(--tpl-text, #09090b)', + }} + > + {day.label} + </span> + {rows[0]?.from && last?.to && ( + <span className="text-[11px] opacity-60"> + {rows[0].from}–{last.to} + {spanLabel(rows) && ` · ${spanLabel(rows)}`} + </span> + )} + </div> + + <div className="space-y-1.5 px-4 pt-3.5"> + <h4 className="serif text-lg font-bold">{plan.name}</h4> + {plan.why && ( + <p className="text-[13px] leading-relaxed opacity-80">{plan.why}</p> + )} + </div> + + {/* 지도는 카드 폭을 꽉 채운다 — 여백을 두면 접힌 카드에서 지도가 잘려 보인다. */} + {points.length > 0 && <TripMap points={points} />} + + <ol className="mt-3 px-4 pb-3"> + {rows.map(({stop, from, to}, stopIndex) => ( + <li + key={`${stop.name}-${stopIndex}`} + className="grid grid-cols-[46px_minmax(0,1fr)] gap-2.5" + > + <span className="serif pt-2 text-[12px] tabular-nums opacity-75">{from ?? ''}</span> + <div className="border-l pb-2 pl-3" style={{borderColor: ITEM_BORDER}}> + <div className="flex items-start gap-2.5 pt-1"> + {stop.imageUrl && ( + <img + src={stop.imageUrl} + alt={`${stop.name} 사진`} + loading="lazy" + decoding="async" + className="size-12 shrink-0 object-cover" + style={{border: `1px solid ${ITEM_BORDER}`}} + /> + )} + <span className="min-w-0"> + <span className="block text-sm font-bold"> + <span + className="mr-1.5 inline-grid size-[17px] translate-y-px place-items-center rounded-full text-[10px] tabular-nums" + style={{backgroundColor: ITEM_ACCENT, color: 'var(--tpl-bg, #ffffff)'}} + > + {stopIndex + 1} + </span> + {stop.name} + </span> + {stop.note && ( + <span className="mt-0.5 block text-[12px] leading-relaxed opacity-75"> + {stop.note} + </span> + )} + </span> + </div> + <p className="mt-0.5 text-[10px] opacity-50"> + {from && to ? `${from}–${to}` : ''} + {stop.searchQuery && ` · 지도 검색 ${stop.searchQuery}`} + </p> + </div> + </li> + ))} + </ol> + + <div className="border-t border-dashed px-4 py-2.5" style={{borderColor: ITEM_BORDER}}> + <SourceLine source={plan.source} verified={plan.verified} /> + </div> + </article> + ); + })} + </div> + </div> + ))} + </div> + </ItemSection> + ); +} diff --git a/solution/site/src/sections/items/PeopleSection.tsx b/solution/site/src/sections/items/PeopleSection.tsx index 7ed82b7..2665f6d 100644 --- a/solution/site/src/sections/items/PeopleSection.tsx +++ b/solution/site/src/sections/items/PeopleSection.tsx @@ -16,27 +16,45 @@ export function PeopleSection() { <ItemSection id="people" name={parsed.title || sectionName(payload, 'people', '인물 열전')} - subtitle={parsed.subtitle} + subtitle={parsed.subtitle ?? '이 도시가 배출한 이름들'} count={`${tally(parsed.items.length, parsed.unverified, '명')} · 사진이 없는 인물은 이름 활자로 대신합니다`} dark > - <div className="border border-white/10" style={{backgroundColor: ITEM_INVERSE}}> + <div + className="tpl-border border" + style={{ + backgroundColor: ITEM_INVERSE, + borderColor: 'color-mix(in oklab, currentColor 22%, transparent)', + }} + > <div className="w4-film-perf h-2.5 opacity-70" /> <div className="px-3 py-4"> <Rail label="인물 목록"> {parsed.items.map((person, index) => ( <article key={`${person.name}-${index}`} className="w-[210px] shrink-0 snap-center"> - <span - className="serif grid aspect-3/4 w-full place-items-center border text-5xl" - // 프레임 안쪽은 필름보다 한 단 밝게 — 색을 박지 않고 글자색을 섞어 만든다. - style={{ - backgroundColor: 'color-mix(in oklab, currentColor 14%, transparent)', - borderColor: 'color-mix(in oklab, currentColor 28%, transparent)', - }} - aria-hidden - > - {person.name.trim().charAt(0)} - </span> + {person.imageUrl ? ( + <img + src={person.imageUrl} + alt={`${person.name} 사진`} + loading="lazy" + decoding="async" + className="aspect-3/4 w-full border object-cover" + style={{borderColor: 'color-mix(in oklab, currentColor 28%, transparent)'}} + /> + ) : ( + <span + className="serif grid aspect-3/4 w-full place-items-center border text-5xl" + // 사진이 없으면 빈 상자 대신 활판 이니셜. 프레임 안쪽은 필름보다 한 단 밝게 — + // 색을 박지 않고 글자색을 섞어 만든다. + style={{ + backgroundColor: 'color-mix(in oklab, currentColor 14%, transparent)', + borderColor: 'color-mix(in oklab, currentColor 28%, transparent)', + }} + aria-hidden + > + {person.name.trim().charAt(0)} + </span> + )} <h3 className="serif mt-3 text-base font-bold"> {person.name} {person.aka && <span className="ml-1.5 text-xs opacity-60">호 {person.aka}</span>} diff --git a/solution/site/src/sections/items/PostcardSection.tsx b/solution/site/src/sections/items/PostcardSection.tsx index fefdbc8..617ed5b 100644 --- a/solution/site/src/sections/items/PostcardSection.tsx +++ b/solution/site/src/sections/items/PostcardSection.tsx @@ -17,7 +17,7 @@ export function PostcardSection() { <ItemSection id="postcard" name={parsed.title || sectionName(payload, 'postcard', '오늘의 엽서')} - subtitle={parsed.subtitle} + subtitle={parsed.subtitle ?? '그대로 붙여 쓰는 한 문장'} count={tally(parsed.items.length, parsed.unverified, '장')} > <Rail label="엽서 목록"> @@ -27,6 +27,16 @@ export function PostcardSection() { className="w4-paper w-[304px] shrink-0 snap-center border p-4" style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}} > + {card.imageUrl && ( + <img + src={card.imageUrl} + alt={`${card.place ?? card.postmark ?? ''} 사진`} + loading="lazy" + decoding="async" + className="mb-3.5 block aspect-3/2 w-full border object-cover" + style={{borderColor: ITEM_BORDER}} + /> + )} <div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3"> {/* 가운데 괘선 — 엽서 뒷면을 반으로 가르는 그 선 */} <div className="min-w-0 border-r pr-3" style={{borderColor: ITEM_BORDER}}> diff --git a/solution/site/src/sections/items/SongsSection.tsx b/solution/site/src/sections/items/SongsSection.tsx index 86e4365..e9c7024 100644 --- a/solution/site/src/sections/items/SongsSection.tsx +++ b/solution/site/src/sections/items/SongsSection.tsx @@ -1,78 +1,151 @@ /** * 가요 다방 — 발행본. * - * 캔버스는 턴테이블에 한 장만 얹지만 발행본은 곡마다 판을 하나씩 놓는다. - * 인용될 문장(story·connection)이 전부 HTML 에 있어야 하기 때문이다. + * ★ 마크업은 목업(/s/stay 의 `<section id="songs">`)에서 그대로 옮겼다. + * 턴테이블 하나에 판을 갈아 끼우고, 아래 미니 판으로 곡을 고른다. + * ★ **전 곡을 HTML 에 굽고 화면에서만 접는다**(`hidden`). 접힌 곡이 HTML 에 없으면 + * 이 섹션을 둔 이유(인용)가 사라진다. * ★ 가사는 어디에도 없다 — 스키마에 lyrics 칸 자체가 없다(원문 전재 금지). + * 그 사실을 화면에도 적는다. 손님이 "왜 가사가 없나" 를 묻지 않게. */ +import {useState} from 'react'; +import {Youtube} from 'lucide-react'; import type {SongItem} from '@o2o/shared'; import {useSite} from '@/lib/site-context'; import {sectionItems, sectionName} from '@/lib/derive'; -import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ITEM_INVERSE, ItemSection, Rail, SourceLine, tally} from './common'; +import {ITEM_ACCENT, ITEM_INVERSE, ItemSection, SourceLine, tally} from './common'; -/** 곡이 라벨 색을 안 주면 템플릿 강조색이 라벨이 된다. */ -const FALLBACK_LABEL = ITEM_ACCENT; +/** 라벨 색을 안 주는 곡이 대부분이다. 순환시키면 미니 판이 서로 구별된다. */ +const LABEL_COLORS = ['#d4551f', '#1d3c74', '#2a6053', '#7a3b6b', '#8a6d1f']; export function SongsSection() { const payload = useSite(); const parsed = sectionItems<SongItem>(payload, 'songs'); + const [active, setActive] = useState(0); if (parsed.items.length === 0) return null; + const labelOf = (song: SongItem, index: number) => + song.labelColor || LABEL_COLORS[index % LABEL_COLORS.length]; + return ( <ItemSection id="songs" name={parsed.title || sectionName(payload, 'songs', '가요 다방')} - subtitle={parsed.subtitle} + subtitle={parsed.subtitle ?? '이 도시를 노래한 곡들'} count={tally(parsed.items.length, parsed.unverified, '곡')} > - <Rail label="곡 목록"> - {parsed.items.map((song, index) => ( - <article - key={`${song.title}-${index}`} - className="w4-paper w-[280px] shrink-0 snap-center border p-5" - style={{backgroundColor: ITEM_CARD, borderColor: ITEM_BORDER}} + <div + className="tpl-border grid items-center gap-8 border p-6 sm:p-8 md:grid-cols-[240px_minmax(0,1fr)]" + style={{ + backgroundColor: ITEM_INVERSE, + color: 'var(--tpl-bg, #ffffff)', + borderColor: 'color-mix(in oklab, currentColor 22%, transparent)', + }} + > + <div className="relative mx-auto size-[220px]"> + <div + className="absolute inset-0 rounded-full" + style={{backgroundColor: 'color-mix(in oklab, currentColor 16%, transparent)'}} + /> + <div + className="w4-disc w4-spin absolute inset-2 rounded-full" + style={{ + ['--lbl' as string]: labelOf(parsed.items[active], active), + ['--w4-vinyl' as string]: ITEM_INVERSE, + }} > - <div - className="w4-disc mx-auto size-[132px] rounded-full" - style={{ - ['--lbl' as string]: song.labelColor || FALLBACK_LABEL, - ['--w4-vinyl' as string]: ITEM_INVERSE, - }} - > - <span - className="block size-full rounded-full" - style={{boxShadow: 'inset 0 0 0 1px rgba(255,255,255,.06)'}} - /> - </div> + <span + className="absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full" + style={{backgroundColor: 'color-mix(in oklab, currentColor 30%, transparent)'}} + /> + </div> + {/* 톤암 — 판 위에 얹힌 바늘. 이게 없으면 그냥 검은 원이다. */} + <div aria-hidden="true" className="absolute -right-1 top-3 h-2 w-[110px] origin-right rotate-6"> + <span + className="absolute inset-y-[3px] left-0 right-4 rounded-sm" + style={{backgroundColor: 'color-mix(in oklab, currentColor 65%, transparent)'}} + /> + <span + className="absolute -top-2 right-0 size-6 rounded-full" + style={{backgroundColor: 'color-mix(in oklab, currentColor 55%, transparent)'}} + /> + </div> + </div> - <h3 className="serif mt-4 text-lg font-bold">{song.title}</h3> - <p className="mt-1 text-xs opacity-65"> - {[ - song.artist, - song.year ? String(song.year) : undefined, - song.lyricist || song.composer - ? `작사 ${song.lyricist ?? '미상'} / 작곡 ${song.composer ?? '미상'}` - : undefined, - song.label, - ] - .filter(Boolean) - .join(' · ')} - </p> - {song.story && <p className="mt-3 text-sm leading-relaxed opacity-85">{song.story}</p>} - {song.connection && ( - <p className="mt-2 text-sm leading-relaxed" style={{color: ITEM_ACCENT}}> - {song.connection} + <div className="min-w-0"> + {parsed.items.map((song, index) => ( + <div key={`${song.title}-${index}`} hidden={index !== active} className="space-y-3"> + <p className="text-[10px] tracking-[0.24em]" style={{color: ITEM_ACCENT}}> + A면 · {index + 1} / {parsed.items.length} </p> - )} - <p className="mt-3 border border-dashed px-2 py-1 text-[10px] opacity-60" style={{borderColor: ITEM_BORDER}}> - ◎ 가사 대신 이야기 — 원문은 싣지 않습니다 - </p> - <div className="mt-2"> + <h3 className="serif text-[length:var(--fs-h2)] leading-tight">{song.title}</h3> + <p className="text-[length:var(--fs-xs)] opacity-60"> + {[ + song.artist, + song.year ? String(song.year) : undefined, + song.lyricist || song.composer + ? `작사 ${song.lyricist ?? '미상'} / 작곡 ${song.composer ?? '미상'}` + : undefined, + song.label, + ] + .filter(Boolean) + .join(' · ')} + </p> + {song.story && ( + <p className="measure text-[length:var(--fs-sm)] leading-relaxed opacity-85">{song.story}</p> + )} + {song.connection && ( + <p className="measure text-[length:var(--fs-sm)] leading-relaxed" style={{color: ITEM_ACCENT}}> + {song.connection} + </p> + )} + <a + href={`https://www.youtube.com/results?search_query=${encodeURIComponent( + `${song.title} ${song.artist ?? ''}`.trim(), + )}`} + target="_blank" + rel="noopener noreferrer nofollow" + className="inline-flex items-center gap-1.5 text-[length:var(--fs-sm)] font-bold underline-offset-4 hover:underline" + style={{color: ITEM_ACCENT}} + > + <Youtube className="size-4" aria-hidden="true" /> + <span>유튜브에서 듣기</span> + </a> + <span + className="block w-fit border border-dashed px-2 py-1 text-[10px] tracking-[0.1em] opacity-60" + style={{borderColor: 'color-mix(in oklab, currentColor 35%, transparent)'}} + > + ◎ 가사 대신 이야기 — 원문은 싣지 않습니다 + </span> <SourceLine source={song.source} verified={song.verified} /> </div> - </article> - ))} - </Rail> + ))} + </div> + </div> + + {parsed.items.length > 1 && ( + <div className="mt-4 flex flex-wrap justify-center gap-4"> + {parsed.items.map((song, index) => ( + <button + key={`${song.title}-pick-${index}`} + type="button" + aria-pressed={index === active} + onClick={() => setActive(index)} + className="w-24 text-center" + > + <span + className="w4-disc-mini mx-auto block size-[76px] rounded-full transition-transform hover:scale-105" + style={{ + ['--lbl' as string]: labelOf(song, index), + ['--w4-vinyl' as string]: ITEM_INVERSE, + boxShadow: index === active ? `0 0 0 2px ${ITEM_ACCENT}` : undefined, + }} + /> + <span className="mt-2 block truncate text-[10px] leading-tight opacity-70">{song.title}</span> + </button> + ))} + </div> + )} </ItemSection> ); } diff --git a/solution/site/src/sections/items/TripMap.tsx b/solution/site/src/sections/items/TripMap.tsx new file mode 100644 index 0000000..5413a7f --- /dev/null +++ b/solution/site/src/sections/items/TripMap.tsx @@ -0,0 +1,128 @@ +/** + * 일정 지도 — OpenStreetMap 타일을 직접 깔고 그 위에 번호 핀을 찍는다. + * + * ★ 왜 iframe 이 아닌가 + * iframe 임베드는 핀을 하나밖에 못 찍고, 순서(1→2→3)를 그릴 수 없다. 일정에서 중요한 건 + * "어디"가 아니라 "어떤 순서로 도는가"라 경로가 보여야 한다. + * ★ 타일은 회색으로 눌러 깐다(`grayscale opacity .55`). 지도가 선명하면 핀과 경로가 묻힌다. + * ★ 핀을 누르면 네이버 길찾기로 간다 — 앞 정거장에서 이 정거장까지. 우리가 경로를 + * 계산해 그리지 않는다(지어낸 시간을 그릴 수 없다). + */ +const TILE = 256; + +/** 경위도 → Web Mercator 픽셀(줌 z 기준). */ +function project(lat: number, lng: number, z: number) { + const n = 2 ** z; + const x = ((lng + 180) / 360) * n * TILE; + const s = Math.sin((lat * Math.PI) / 180); + const y = ((0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * n) * TILE; + return {x, y}; +} + +export interface TripPoint { + name: string; + latitude: number; + longitude: number; + searchQuery?: string; +} + +export function TripMap({points, width = 320, height = 168}: { + points: TripPoint[]; + width?: number; + height?: number; +}) { + if (points.length === 0) return null; + + const lats = points.map((p) => p.latitude); + const lngs = points.map((p) => p.longitude); + const center = {lat: (Math.min(...lats) + Math.max(...lats)) / 2, lng: (Math.min(...lngs) + Math.max(...lngs)) / 2}; + + // 모든 점이 상자 안에 들어오는 가장 큰 줌. 한 점뿐이면 동네가 보이는 15로 둔다. + let zoom = 15; + for (let z = 16; z >= 10; z -= 1) { + const xs = points.map((p) => project(p.latitude, p.longitude, z).x); + const ys = points.map((p) => project(p.latitude, p.longitude, z).y); + const w = Math.max(...xs) - Math.min(...xs); + const h = Math.max(...ys) - Math.min(...ys); + if (w <= width - 56 && h <= height - 56) { + zoom = z; + break; + } + } + + const c = project(center.lat, center.lng, zoom); + const originX = c.x - width / 2; + const originY = c.y - height / 2; + + // 화면을 덮는 타일만 깐다. + const tiles: {x: number; y: number; left: number; top: number}[] = []; + const tx0 = Math.floor(originX / TILE); + const ty0 = Math.floor(originY / TILE); + const tx1 = Math.floor((originX + width) / TILE); + const ty1 = Math.floor((originY + height) / TILE); + for (let tx = tx0; tx <= tx1; tx += 1) { + for (let ty = ty0; ty <= ty1; ty += 1) { + tiles.push({x: tx, y: ty, left: tx * TILE - originX, top: ty * TILE - originY}); + } + } + + const naverTo = (from: TripPoint | undefined, to: TripPoint) => { + const dest = `${to.longitude},${to.latitude},${encodeURIComponent(to.name)}`; + const start = from ? `${from.longitude},${from.latitude},${encodeURIComponent(from.name)}` : '-'; + return `https://map.naver.com/p/directions/${start}/${dest}/-/transit`; + }; + + return ( + <div + className="relative mt-3.5 overflow-hidden border-y" + style={{ + height, + borderColor: 'var(--tpl-border, #d6d3d1)', + // ★ 타일이 로드되기 전에도 지도 자리가 비어 보이지 않게 — OSM 육지 색이다. + backgroundColor: '#e8e5df', + }} + aria-label="일정 지도" + > + {tiles.map((tile) => ( + <img + key={`${tile.x}-${tile.y}`} + src={`https://tile.openstreetmap.org/${zoom}/${tile.x}/${tile.y}.png`} + alt="" + width={TILE} + height={TILE} + loading="lazy" + decoding="async" + className="pointer-events-none absolute max-w-none" + style={{left: tile.left, top: tile.top, filter: 'grayscale(1) contrast(0.95) opacity(0.55)'}} + /> + ))} + + {points.map((point, index) => { + const p = project(point.latitude, point.longitude, zoom); + return ( + <a + key={`${point.name}-${index}`} + href={naverTo(points[index - 1], point)} + target="_blank" + rel="noopener noreferrer" + title={`${point.name} — 네이버 지도`} + className="absolute grid h-[22px] min-w-[22px] -translate-x-1/2 -translate-y-1/2 place-items-center whitespace-nowrap rounded-full px-1.5 text-[11px] font-bold shadow-sm transition-transform hover:scale-110" + style={{ + left: p.x - originX, + top: p.y - originY, + backgroundColor: 'color-mix(in oklab, var(--tpl-accent, #2563eb) 70%, currentColor)', + color: 'var(--tpl-bg, #ffffff)', + border: '1.5px solid var(--tpl-bg, #ffffff)', + }} + > + {index + 1} + </a> + ); + })} + + <span className="absolute bottom-0.5 right-1 bg-white/70 px-1 text-[9px] leading-tight text-stone-600"> + © OpenStreetMap + </span> + </div> + ); +} diff --git a/solution/site/src/sections/items/VideoSection.tsx b/solution/site/src/sections/items/VideoSection.tsx new file mode 100644 index 0000000..e890fdd --- /dev/null +++ b/solution/site/src/sections/items/VideoSection.tsx @@ -0,0 +1,50 @@ +/** + * 영상. + * + * ★ 자동 재생하지 않고 iframe 도 심지 않는다 — 외부 스크립트를 끌어오면 첫 화면이 늦어지고 + * 추적 쿠키가 따라 들어온다. 썸네일과 링크만 둔다. + */ +import {Play} from 'lucide-react'; +import type {VideoItem} from '@o2o/shared'; +import {useSite} from '@/lib/site-context'; +import {sectionItems, sectionName} from '@/lib/derive'; +import {ITEM_BORDER, ITEM_INVERSE, ITEM_INVERSE_INK, ItemSection, tally} from './common'; + +export function VideoSection() { + const payload = useSite(); + const parsed = sectionItems<VideoItem>(payload, 'video'); + if (parsed.items.length === 0) return null; + + return ( + <ItemSection + id="video" + name={parsed.title || sectionName(payload, 'video', '영상')} + subtitle={parsed.subtitle} + count={tally(parsed.items.length, parsed.unverified, '편')} + > + <ul className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3"> + {parsed.items.map((video, index) => ( + <li key={`${video.url}-${index}`}> + <a + href={video.url} + target="_blank" + rel="noopener noreferrer" + className="group block border-2" + style={{borderColor: ITEM_BORDER}} + > + <div + className="flex aspect-video items-center justify-center" + style={{backgroundColor: ITEM_INVERSE, color: ITEM_INVERSE_INK}} + > + <Play className="size-10 opacity-70 transition-opacity group-hover:opacity-100" aria-hidden="true" /> + </div> + {video.caption && ( + <p className="px-3 py-2 text-[length:var(--fs-sm)] leading-snug">{video.caption}</p> + )} + </a> + </li> + ))} + </ul> + </ItemSection> + ); +} diff --git a/solution/site/src/sections/items/common.tsx b/solution/site/src/sections/items/common.tsx index bf090e0..1080b2e 100644 --- a/solution/site/src/sections/items/common.tsx +++ b/solution/site/src/sections/items/common.tsx @@ -56,35 +56,47 @@ export function ItemSection({ <section id={id} aria-labelledby={`${id}-heading`} - className="w-full border-b" + className="border-line paper w-full border-b" style={{ backgroundColor: dark ? ITEM_INVERSE : 'var(--tpl-surface, #fafafa)', color: dark ? ITEM_INVERSE_INK : ITEM_INK, - borderColor: 'color-mix(in oklab, currentColor 12%, transparent)', - paddingBlock: 'var(--tpl-section-space, 4rem)', + paddingBlock: 'var(--section-space)', }} > <div className="shell"> - <h2 id={`${id}-heading`} className="serif text-2xl tracking-tight sm:text-3xl lg:text-4xl"> - {name} - </h2> - {subtitle && <p className="mt-2 text-sm opacity-70">{subtitle}</p>} - <div className="mt-8">{children}</div> - {count && <p className="mt-4 text-xs opacity-55">{count}</p>} + {/* ★ 머리말은 목업(/s/stay 의 아이템 섹션)과 같은 모양이다 — 다른 섹션과 달리 + 세로 막대를 달지 않는다. 이야기 섹션은 그 자체가 한 덩어리라 구분선이 필요 없다. */} + <header className="mb-8 sm:mb-10"> + <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2"> + <h2 id={`${id}-heading`} className="h2"> + {name} + </h2> + </div> + {subtitle && ( + <p className="measure mt-3 text-[length:var(--fs-sm)] opacity-70">{subtitle}</p> + )} + </header> + {children} + {count && <p className="mt-5 text-[length:var(--fs-xs)] opacity-55">{count}</p>} </div> </section> ); } -/** 가로로 미는 레일. 화살표 버튼은 두지 않는다 — 스크립트 없이도 손가락·트랙패드로 민다. */ +/** + * 가로로 미는 레일. 화살표 버튼은 두지 않는다 — 스크립트 없이도 손가락·트랙패드로 민다. + * + * ★ 클래스 이름(`slider-viewport`/`slider-track`)은 목업과 같게 둔다. 스크롤 스냅만으로 + * 같은 동작이 나오므로 캐러셀 라이브러리는 쓰지 않는다. + */ export function Rail({children, label}: {children: ReactNode; label: string}) { return ( - <div - role="group" - aria-label={label} - className="w4-scroll flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3" - > - {children} + <div className="relative"> + <div className="slider-viewport" role="group" aria-roledescription="캐러셀" aria-label={label} tabIndex={0}> + <div className="slider-track" style={{gap: '1rem'}}> + {children} + </div> + </div> </div> ); } diff --git a/solution/site/src/sections/items/index.ts b/solution/site/src/sections/items/index.ts index 81196a9..f3e5ff9 100644 --- a/solution/site/src/sections/items/index.ts +++ b/solution/site/src/sections/items/index.ts @@ -5,6 +5,9 @@ * 한쪽에만 있으면 사장님이 켜 둔 섹션이 발행본에서 말없이 사라진다(HomePage 주석과 같은 함정). */ import {SongsSection} from './SongsSection'; +import {ItinerarySection} from './ItinerarySection'; +import {EventSection} from './EventSection'; +import {VideoSection} from './VideoSection'; import {DailySection} from './DailySection'; import {CourseSection} from './CourseSection'; import {ScheduleSection} from './ScheduleSection'; @@ -26,4 +29,7 @@ export const ITEM_SECTIONS: Record<string, () => React.ReactElement | null> = { postcard: PostcardSection, quiz: QuizSection, planner: PlannerSection, + itinerary: ItinerarySection, + event: EventSection, + video: VideoSection, }; diff --git a/solution/site/src/sections/items/items.css b/solution/site/src/sections/items/items.css index f23f7e2..9c5a081 100644 --- a/solution/site/src/sections/items/items.css +++ b/solution/site/src/sections/items/items.css @@ -107,3 +107,27 @@ transition: none; } } + +/* + * 날씨 카드의 하늘. + * + * ★ 색을 박지 않는다 — 템플릿이 갱지든 흰 바탕이든 그 위에 얹히는 결이라 + * 글자색을 섞어 만든다. data-mood 로 결의 방향과 세기만 바꾼다. + */ +.w4-sky { + position: absolute; + inset: 0; + pointer-events: none; + background-image: + radial-gradient(120% 80% at 80% 0%, color-mix(in oklab, currentColor 7%, transparent) 0%, transparent 60%), + linear-gradient(180deg, color-mix(in oklab, currentColor 4%, transparent) 0%, transparent 55%); +} +.w4-sky[data-mood*="비"], +.w4-sky[data-mood*="눈"] { + background-image: + repeating-linear-gradient(105deg, color-mix(in oklab, currentColor 8%, transparent) 0 1px, transparent 1px 9px), + linear-gradient(180deg, color-mix(in oklab, currentColor 6%, transparent) 0%, transparent 60%); +} +.w4-sky[data-mood*="맑"] { + background-image: radial-gradient(60% 60% at 82% 12%, color-mix(in oklab, currentColor 12%, transparent) 0%, transparent 70%); +} diff --git a/solution/site/src/seo/head.ts b/solution/site/src/seo/head.ts index 4cdfb5c..ded7d2c 100644 --- a/solution/site/src/seo/head.ts +++ b/solution/site/src/seo/head.ts @@ -87,6 +87,9 @@ export function themeStyle(payload: SitePayload): string { ['--tpl-border-width', safe(look.borderWidth)], ['--tpl-radius', safe(look.radius)], ['--tpl-shadow', safe(look.shadow)], + // ★ 종이 질감. `.paper` 가 이 값을 읽는다 — 없으면 섹션 바탕이 평평한 색면으로만 남는다. + // 토큰이 비어 있으면 아무것도 깔지 않는다(질감을 원하지 않는 템플릿이 있다). + ['--tpl-texture', safe(look.texture)], ]; for (const [name, value] of pairs) if (value) lines.push(`${name}: ${value};`); } diff --git a/solution/site/src/seo/verify.ts b/solution/site/src/seo/verify.ts index 9a34007..f0dee75 100644 --- a/solution/site/src/seo/verify.ts +++ b/solution/site/src/seo/verify.ts @@ -22,6 +22,12 @@ const STRUCTURAL = new Set([ '@type', '@id', 'priceCurrency', + // ★ ㎡의 ISO 코드('MTK'). priceCurrency 와 같은 성격이라 화면에 나올 값이 아니다 — + // 빠져 있어서 객실 면적(room_size)이 있는 사업장은 전부 발행이 막혔다(실측: 가은채 객실 12개). + 'unitCode', + // ★ units.length 로 만든 파생 카운트다. 화면에는 객실 카드가 그 개수만큼 있을 뿐 + // '12' 라는 숫자가 글자로 있지는 않다. 객실이 2~3개인 사업장만 우연히 통과했다. + 'numberOfRooms', 'addressCountry', 'inLanguage', // 좌표는 지도 핀용 메타지 본문에 쓸 값이 아니다. 대신 payload 와 직접 대조한다(verifyGeo). From 94551afdaf904615ba98117033f1efb0c040b2db Mon Sep 17 00:00:00 2001 From: Mina Choi <mnchoi@o2o.kr> Date: Tue, 8 Sep 2026 13:01:14 +0900 Subject: [PATCH 16/18] =?UTF-8?q?[refactor]=20solution/backend,frontend,po?= =?UTF-8?q?stgres-init:=20=ED=9A=8C=EC=82=AC(=ED=85=8C=EB=84=8C=ED=8A=B8)?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20=E2=80=94=20=EC=82=AC=EC=9E=A5=EB=8B=98?= =?UTF-8?q?=20=EA=B3=84=EC=A0=95=EC=9D=B4=20=EA=B3=A7=20=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=ED=94=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 가입 한 번이 회사를 하나 만들고 사장님이 그 회사의 직원이 됐다. 가입 폼은 "상호"를 묻고 에디터 헤더에는 "이름 · 회사명" 이 붙었다 — 쓰는 사람은 사장님 한 명인데. negodata 보일러플레이트의 멀티테넌트 스코프 키를 그대로 물려받은 것이고, DECISIONS.md 2절이 "대행사/운영사 단위로 그대로 쓴다" 로 유지 결정을 적어 뒀던 자리다. - gmodel: `UserInfo.company_id` 삭제 — JWT 클레임에서도 사라진다. 스코프 키는 `user_id` 다 - place_crud·site_crud: WHERE 를 `places.owner_user_id` 로. `list_company_sites` → `list_owner_sites` - place_service: **주인은 토큰이 정한다.** `Req_CreatePlace.owner_user_id` 를 없앴다 — body 로 받으면 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다. 실측: 기존 92건은 아무도 안 보내서 전부 NULL 이었고 스코프는 회사가 대신 하고 있었다 - 워커(collect·copy·build·vision): 잡 페이로드 키 `company_id` → `owner_user_id`. 잡이 세우는 `UserInfo.user_id` 는 이제 **사업장 주인**이다 — 예전엔 요청자·검증자·랜덤 uuid 순으로 채웠는데, 그 랜덤 uuid 가 스코프 키가 되는 순간 "남의 사업장" 이라 fact 조회가 0건이 된다 - auth: `Res_Me.company` · `Req_Signup.company_name` · `CompanyData` 삭제 - models·init.sql: `company.companies` 테이블 · `users.company_id` 삭제, `places.owner_user_id` NOT NULL. 마이그레이션은 백필 → NOT NULL → DROP 순서다. 회사에 계정이 여럿이면 **가장 먼저 만든 계정**에게 몰고, 주인을 못 찾은 행은 지운다 — 스코프가 없으면 아무에게도 안 보이는 유령이다. 실측(로컬): place 92 → 91(고아 1건 삭제), `demoebf050` 56 · `test` 35 - 프론트: 가입 폼의 상호 칸, 내 정보의 상호 항목, 헤더의 "이름 · 회사명" 삭제 - 테스트: `company_id`/`other_company_id` 픽스처 → `owner_id` 하나. 격리는 `auth_headers("o2")` 를 한 번 더 부르면 그게 남이다 남긴 것 — DB 스키마 이름 `company` 는 그대로다. rename 은 모든 모델의 `__table_args__` 를 건드려야 해서 이번 변경에 섞지 않았다. 검증: 전체 568 passed(실패 1건은 HEAD 에서도 깨지는 레이트리밋 테스트) · 프론트 tsc+eslint 통과 · 실제 API 로 가입→사업장→목록→격리→발행 한 바퀴 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLWEFx4X3XRmKewUKjJWow --- docs/ARCHITECTURE.md | 7 +- docs/DECISIONS.md | 4 +- docs/DEVLOG.md | 33 +++++++ postgres-init/init-data/init.sql | 54 +++++------ .../backend/common/database/model/models.py | 24 +---- solution/backend/common/models/gmodel.py | 3 +- solution/backend/conftest.py | 57 ++++++------ solution/backend/crud/place_crud.py | 26 +++--- solution/backend/crud/site_crud.py | 8 +- solution/backend/crud/user_crud.py | 90 +------------------ solution/backend/router/v1/auth/protocol.py | 13 +-- solution/backend/router/v1/place/place.py | 2 +- solution/backend/router/v1/place/protocol.py | 7 +- solution/backend/scripts/demo_build.py | 10 +-- solution/backend/scripts/demo_pipeline.py | 9 +- solution/backend/services/auth_service.py | 48 +++------- solution/backend/services/collect_service.py | 27 +++--- solution/backend/services/copy_service.py | 13 +-- solution/backend/services/fact_service.py | 2 +- solution/backend/services/faq_service.py | 6 +- solution/backend/services/media_service.py | 2 +- solution/backend/services/place_service.py | 31 ++++--- solution/backend/services/showcase_service.py | 2 +- solution/backend/services/vision_service.py | 6 +- solution/backend/tests/test_auth.py | 10 +-- solution/backend/tests/test_build_publish.py | 6 +- solution/backend/tests/test_collect_api.py | 6 +- .../backend/tests/test_collect_pipeline.py | 8 +- solution/backend/tests/test_fact_api.py | 6 +- solution/backend/tests/test_fact_schema.py | 30 +++---- solution/backend/tests/test_faq_api.py | 6 +- solution/backend/tests/test_my_sites.py | 8 +- solution/backend/tests/test_place.py | 12 +-- solution/backend/tests/test_place_search.py | 2 +- solution/backend/tests/test_seo_audit.py | 4 +- solution/backend/tests/test_showcase_api.py | 26 +++--- solution/backend/tests/test_site_slug.py | 6 +- solution/backend/tests/test_site_template.py | 6 +- solution/backend/tests/test_site_theme.py | 6 +- solution/backend/tests/test_snapshot.py | 50 +++++------ .../backend/tests/test_verify_candidates.py | 6 +- .../src/api/generated/model/companyData.ts | 11 --- .../api/generated/model/placeSearchItem.ts | 2 +- .../src/api/generated/model/reqSignup.ts | 4 +- .../generated/model/reqSignupCompanyName.ts | 8 -- .../frontend/src/api/generated/model/resMe.ts | 2 - .../src/api/generated/model/resMeCompany.ts | 9 -- .../src/api/generated/model/showcaseItem.ts | 2 +- .../src/components/layout/AppShell.tsx | 1 - solution/frontend/src/pages/AccountPage.tsx | 11 +-- solution/frontend/src/pages/BuilderPage.tsx | 3 +- solution/frontend/src/pages/SignupPage.tsx | 13 --- solution/frontend/src/stores/auth.ts | 4 - 53 files changed, 296 insertions(+), 456 deletions(-) delete mode 100644 solution/frontend/src/api/generated/model/companyData.ts delete mode 100644 solution/frontend/src/api/generated/model/reqSignupCompanyName.ts delete mode 100644 solution/frontend/src/api/generated/model/resMeCompany.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2f0c996..96df61c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -186,10 +186,11 @@ USER role=1 → 403 DEVELOPER role=3 → 200 OWNER role=2 → 403 ``` -OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다. +OWNER 가 막히는 게 핵심이다 — 내부 운영 화면을 볼 권한이 아니다. `auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `signup`·`google` 도 -같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고 자기 -회사(새 테넌트) 하나만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다. +같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고 +`places.owner_user_id` 가 자기 계정인 사업장만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다. +(2026-09-08 전에는 이 스코프가 회사(테넌트)였다 — DECISIONS.md 2절) ⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`). 위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 가 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index cd6d4a5..4de16a1 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -88,9 +88,9 @@ | 포트 | **9800** | negosium 9300 / negodata 9400 / agent 9500 / lps 9600 / anchoring 9700 다음 번호 | | DB | `web4ai_db` (테스트 `web4ai_test_db`), 기존 로컬 postgres(`negosium-db` 컨테이너, 5432) 안의 **별도 database** | 원본과 같은 인스턴스·다른 DB. 스키마 네임스페이스 컨벤션 유지 | | 마이그레이션 | Alembic 안 씀. `postgres-init/init-data/init.sql` **한 벌**(전체 DDL, 재실행 안전) | 2026-08-31: 누적 ALTER 파일(`alters/`)을 없앴다. 아직 git·서버 어디에도 안 올라가 **보정할 기존 DB 가 없다** — init.sql 에 이미 전부 반영돼 있어 두 벌을 유지할 이유가 없었다. 운영 DB 가 생기는 순간 다시 필요해진다 | -| 남긴 것 | config 로더 · 로거 · 싱글톤 · DB 세션 매니저(R/W 분리) · gmodel · gtime · authz · JWT/bcrypt dependencies · `company.companies`/`company.users` · auth 라우터 · 스케줄러 껍데기 · conftest(테스트 DB 자동 생성/삭제) | 전 모듈이 공통으로 쓰는 인프라. 인증은 places·facts·sites 전부가 `IsValidAccessToken` 에 의존한다 | +| 남긴 것 | config 로더 · 로거 · 싱글톤 · DB 세션 매니저(R/W 분리) · gmodel · gtime · authz · JWT/bcrypt dependencies · `company.users` · auth 라우터 · 스케줄러 껍데기 · conftest(테스트 DB 자동 생성/삭제) | 전 모듈이 공통으로 쓰는 인프라. 인증은 places·facts·sites 전부가 `IsValidAccessToken` 에 의존한다 | | 뺀 것 | quotation · supplier · item · card · dashboard · statistics · learning · renegotiation · landing · admin · notification · LPS 연동 · anchoring · 초청메일(ACS/SMTP) · Azure Blob 클라이언트 | negodata 고유 도메인. Blob 클라이언트만 1-2 결론 후 media 모듈과 함께 재이식 예정 | -| `companies` 테이블 유지 | 유지 | 보일러플레이트의 멀티테넌트 스코프 키(`UserInfo.company_id`)가 전 계층에 박혀 있다. 대행사/운영사 단위로 그대로 쓴다 | +| `companies` 테이블 유지 | **2026-09-08 철회 — 걷어냈다** | 보일러플레이트를 그대로 둔 결정이었는데, 이 제품의 사용자는 사장님 한 명이다. 가입 한 번이 회사를 만들고 사장님이 자기 회사의 직원이 되는 구조가 화면에까지 나왔다(가입 폼의 "상호", 헤더의 "이름 · 회사명"). 스코프 키를 `places.owner_user_id` 로 옮기고 `company.companies` 테이블 · `users.company_id` · `UserInfo.company_id` 를 삭제했다. 스키마 이름 `company` 만 남았다 — rename 은 모든 모델의 `__table_args__` 를 건드려서 따로 둔다 | | ErrorType 구간 | 계정 = 1100. 도메인 구간 예약 — places 1200 / facts 1300 / collector 1400 / generator 1500 / local 1600 / sites 1700 / reports 1800 | 원본이 구간을 나눠 쓰는 방식 유지 | | 외부 API 키 | `[ExternalApiConfig]` 로 toml + env override. **키가 비면 해당 어댑터만 비활성, 서버는 그대로 뜬다** | 부팅이 외부 계약에 묶이면 안 됨 | | 백그라운드 작업 | 원본에 전용 작업 큐 없음(APScheduler 크론만). 수집·비전분석·빌드는 몇 분 걸리므로 **큐를 새로 얹어야 한다** — 방식 미정 | 원본에 없는 것이라 팀 컨벤션 확인 필요. 아래 3번 참고 | diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index bdfbe3b..bbd33ea 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,39 @@ --- +## 2026-09-08 — 회사(테넌트)를 걷어냈다 — 사장님 계정이 곧 스코프다 + +**무슨 일** +사장님이 가입하면 회사가 하나 생기고 그 회사의 직원이 됐다. 가입 폼은 "상호"를 묻고, +에디터 헤더에는 "이름 · 회사명" 이 붙었다. 쓰는 사람은 사장님 한 명인데. + +**왜 그랬나** +보일러플레이트(negodata)의 멀티테넌트 스코프 키를 그대로 물려받았다. DECISIONS.md 2절이 +"대행사/운영사 단위로 그대로 쓴다" 로 유지 결정을 적어 뒀던 자리다 — 2026-09-08 철회했다. + +**바꾼 것** +- 스코프 키가 `company_id` → `places.owner_user_id` 다. `UserInfo` 에서 `company_id` 를 뺐고 + (JWT 클레임도 같이 사라진다), `place_crud`·`site_crud` 의 WHERE 가 전부 주인으로 바뀌었다 +- **주인은 토큰이 정한다.** `Req_CreatePlace.owner_user_id` 를 없앴다 — body 로 받으면 남의 + 계정을 적어 만들자마자 남의 목록에 넣을 수 있다. 실측: 기존 92건은 아무도 안 보내서 전부 NULL 이었고, + 스코프는 회사가 대신 하고 있었다 +- 잡 페이로드 키 `company_id` → `owner_user_id`. 워커가 세우는 `UserInfo.user_id` 는 이제 + **사업장 주인**이다 — 예전엔 요청자·검증자·랜덤 uuid 순으로 채웠는데, 그 랜덤 uuid 가 + 스코프 키가 되는 순간 "남의 사업장" 이 되어 fact 조회가 0건이 된다 +- `company.companies` 테이블 · `users.company_id` · `Res_Me.company` · 가입 폼의 상호 칸 삭제 +- 테스트: `company_id`/`other_company_id` 픽스처 → `owner_id` 하나. 격리 테스트는 + `auth_headers("o2")` 를 한 번 더 부르면 그게 남이다 + +**마이그레이션** (`init.sql` 끝, 재실행 안전) +백필 → NOT NULL → 컬럼 삭제 순서다. 회사에 계정이 여럿이던 경우는 **가장 먼저 만든 계정**에게 +몰아준다. 주인을 못 찾은 행은 지운다 — 스코프가 없으면 아무에게도 안 보이는 유령이다. +실측(로컬): 92건 → 91건(고아 1건 삭제), `demoebf050` 56 · `test` 35. + +**남긴 것** — DB 스키마 이름 `company` 는 그대로다. rename 은 모든 모델의 `__table_args__` 를 +건드려야 해서 이번 변경에 섞지 않았다. + +--- + ## 2026-09-07 — (사고 2) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다 **무슨 일** diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 98b42e5..d27ccd2 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -5,7 +5,7 @@ -- 단일 PostgreSQL 인스턴스, 단일 database(web4ai_db) 안에서 도메인별 schema 로 묶는다. -- postgres (1개 서버, 5432) -- └── web4ai_db --- ├── company : companies, users +-- ├── company : users -- ├── place : places, place_aliases, place_links, units, media -- ├── fact : facts, faqs -- ├── local : local_contents, routes, nearby_links @@ -46,28 +46,12 @@ CREATE SCHEMA IF NOT EXISTS site; CREATE SCHEMA IF NOT EXISTS job; -- ============================================================ --- company : 회사 / 내부 유저 +-- company : 계정 +-- ★ 스키마 이름만 company 다. 회사(테넌트) 개념은 2026-09-08 에 걷어냈다 — +-- 스키마 rename 은 모든 모델의 __table_args__ 를 건드려야 해서 따로 둔다. -- ============================================================ -CREATE TABLE IF NOT EXISTS company.companies ( - company_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 회사 식별자(PK) - name VARCHAR(100) NOT NULL, -- 회사명 - business_number VARCHAR(30) NULL, -- 사업자등록번호 - code INTEGER NULL, -- 회사코드 (내부 인덱스용) - representative_name VARCHAR(50) NULL, -- 대표자명 - email VARCHAR(255) NULL, -- 대표 이메일 - contact_number VARCHAR(20) NULL, -- 대표 연락처 - website_url VARCHAR(255) NULL, -- 홈페이지 URL - industry SMALLINT NULL, -- 업종 ( 필요한 만큼 숫자에 매핑하여 사용 ) - status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성) - settings JSONB NULL, -- 회사별 커스터마이징: branding(CI)/labels(용어)/features(동작) - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) - deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 -); - CREATE TABLE IF NOT EXISTS company.users ( user_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 유저 식별자(PK) - company_id uuid NOT NULL, -- 소속 회사(company.companies.company_id) id VARCHAR(64) NOT NULL, -- 로그인 ID (구글 계정은 google_<sub>) password VARCHAR(255) NULL, -- bcrypt 해시. 소셜 계정은 NULL name VARCHAR(50) NULL, -- 이름 @@ -88,8 +72,7 @@ CREATE TABLE IF NOT EXISTS company.users ( -- ============================================================ CREATE TABLE IF NOT EXISTS place.places ( place_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 사업장 식별자(PK) - company_id uuid NOT NULL, -- 테넌트(company.companies.company_id) - owner_user_id uuid NULL, -- 사장님 계정(company.users.user_id) + owner_user_id uuid NOT NULL, -- ★ 스코프 키. 사장님 계정(company.users.user_id) name VARCHAR(200) NOT NULL, -- 상호명(입력값) category SMALLINT NOT NULL, -- 업종(PlaceCategory): 1=숙박 2=카페 3=음식점 4=피부과·성형외과 status SMALLINT NOT NULL DEFAULT 1, -- 상태(PlaceStatus): 1=draft 2=collecting 3=review 4=published 5=suspended @@ -355,15 +338,11 @@ CREATE TABLE IF NOT EXISTS job.jobs ( -- ============================================================ -- 인덱스 -- ============================================================ -CREATE INDEX IF NOT EXISTS idx_users_company_id ON company.users (company_id); -- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다. CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON company.users (id) WHERE deleted = FALSE; -CREATE UNIQUE INDEX IF NOT EXISTS uq_companies_biz_number ON company.companies (business_number) WHERE deleted = FALSE AND business_number IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_companies_code ON company.companies (code) WHERE deleted = FALSE; -- place -CREATE INDEX IF NOT EXISTS idx_places_company_id ON place.places (company_id); CREATE INDEX IF NOT EXISTS idx_places_owner_user_id ON place.places (owner_user_id); CREATE INDEX IF NOT EXISTS idx_places_region_code ON place.places (region_code) WHERE deleted = FALSE; CREATE INDEX IF NOT EXISTS idx_place_aliases_place ON place.place_aliases (place_id); @@ -448,3 +427,26 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON company.users (provid -- 2026-09-03 쇼케이스 카드 썸네일. CREATE TABLE 에만 있어서 기존 DB 가 조용히 깨졌다 -- (실측: 킹서버에서 GET /v1/showcase 가 200 인데 내용은 비었다). ALTER TABLE site.sites ADD COLUMN IF NOT EXISTS thumbnail_url VARCHAR(500) NULL; + +-- 2026-09-08 회사(테넌트) 제거. 쓰는 사람은 사장님 혼자인데 가입 한 번이 회사를 하나 만들고 +-- 그 회사의 직원이 되는 구조였다. 사업장을 계정에 직접 매단다. +-- ★ 순서가 중요하다 — 백필 → NOT NULL → 컬럼 삭제. 반대로 하면 주인을 잃은 행이 남는다. +-- ★ 회사에 계정이 여럿이던 경우(내부 운영 계정)는 **가장 먼저 만든 계정**에게 몰아준다. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='place' AND table_name='places' AND column_name='company_id') THEN + UPDATE place.places p + SET owner_user_id = ( + SELECT u.user_id FROM company.users u + WHERE u.company_id = p.company_id AND u.deleted = FALSE + ORDER BY u.created_at LIMIT 1) + WHERE p.owner_user_id IS NULL; + -- 주인을 못 찾은 행(회사가 통째로 지워진 경우)은 남겨 둘 수 없다 — 스코프가 없으면 아무에게도 안 보인다. + DELETE FROM place.places WHERE owner_user_id IS NULL; + ALTER TABLE place.places ALTER COLUMN owner_user_id SET NOT NULL; + ALTER TABLE place.places DROP COLUMN company_id; + END IF; +END $$; +ALTER TABLE company.users DROP COLUMN IF EXISTS company_id; +DROP TABLE IF EXISTS company.companies; diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index 00f69e4..db6384a 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -10,7 +10,6 @@ from common.enums import ( DBType, UserStatus, UserRole, - CompanyStatus, PlaceStatus, FactStatus, MediaStatus, @@ -45,30 +44,11 @@ class MainTableMixin(_DBTypeMixin): # ERD 도메인 모델 -class companies(MainTableMixin, MAIN_BASE): - __tablename__ = "companies" - __table_args__ = {"schema": "company"} - - company_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - name = Column(String(100), nullable=False) - business_number = Column(String(30), nullable=True) - code = Column(Integer, nullable=True) # 내부 인덱스용 - representative_name = Column(String(50), nullable=True) - email = Column(String(255), nullable=True) - contact_number = Column(String(20), nullable=True) - website_url = Column(String(255), nullable=True) - industry = Column(SmallInteger, nullable=True) # 업종 코드 (스키마 SMALLINT) - status = Column(SmallInteger, nullable=False, default=CompanyStatus.ACTIVE.value) # CompanyStatus - # 회사별 커스터마이징 설정. branding(CI)/labels(용어)/features(동작) - settings = Column(JSONB, nullable=True) - - class users(MainTableMixin, MAIN_BASE): __tablename__ = "users" __table_args__ = {"schema": "company"} user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - company_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 20자였다. 구글 계정의 로그인 아이디를 `google_<sub>`(최대 28자)로 만들면서 넓혔다 — # sub 를 잘라 쓰면 앞자리가 같은 두 계정이 한 아이디로 겹친다. id = Column(String(64), nullable=False, unique=True, index=True) # 로그인 아이디 @@ -102,8 +82,8 @@ class places(MainTableMixin, MAIN_BASE): ) place_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - company_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 테넌트 스코프(company.companies) - owner_user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 사장님 계정(company.users) + # ★ 스코프 키. 사장님 한 명이 자기 가게만 본다 — 회사(테넌트)를 걷어내면서 이 컬럼이 그 자리를 받았다. + owner_user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 사장님 계정(company.users) name = Column(String(200), nullable=False) # 상호명(입력값) category = Column(SmallInteger, nullable=False) # PlaceCategory — 업종 스키마 선택 키 status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PlaceStatus.DRAFT.value) diff --git a/solution/backend/common/models/gmodel.py b/solution/backend/common/models/gmodel.py index bb956fe..6ac550d 100644 --- a/solution/backend/common/models/gmodel.py +++ b/solution/backend/common/models/gmodel.py @@ -67,9 +67,8 @@ class PageParams: class UserInfo(StructModel): """JWT subject 로 인코딩되는 유저 식별 정보.""" - user_id: str # users.user_id (uuid) — 데이터 스코프 키 + user_id: str # users.user_id (uuid) — 데이터 스코프 키. 사업장은 owner_user_id 로 이 값에 매인다 id: str # users.id (로그인 아이디) — get_me 재조회 키 - company_id: str # users.company_id (uuid) — 멀티테넌트 스코프 키 role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키 def __init__(self, *args, **kwargs) -> None: diff --git a/solution/backend/conftest.py b/solution/backend/conftest.py index 4ab1cfd..ec606ed 100644 --- a/solution/backend/conftest.py +++ b/solution/backend/conftest.py @@ -14,7 +14,7 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine from common.database.model.models import MAIN_BASE -from common.enums import CompanyStatus +from common.enums import UserRole, UserStatus from config.server_configs import main_db_config @@ -92,7 +92,7 @@ async def db_engine(_test_db_lifecycle): # 도메인 테이블 전부 비워 격리 (CASCADE: FK 미설정이라 안전망) await conn.execute( text( - "TRUNCATE TABLE users, companies, places, place_aliases, place_links, units, media, " + "TRUNCATE TABLE users, places, place_aliases, place_links, units, media, " "facts, faqs, local_contents, routes, nearby_links, " "sites, site_versions, publish_logs, ai_check_results, jobs RESTART IDENTITY CASCADE" ) @@ -102,30 +102,24 @@ async def db_engine(_test_db_lifecycle): @pytest_asyncio.fixture -async def company_id(db_engine) -> str: - """테스트용 소속사 1개를 시드하고 company_id(uuid str)를 돌려준다. - users 는 company_id 를 요구하므로 계정 생성 테스트의 선행 조건이다. +async def owner_id(db_engine) -> str: + """사장님 계정 1개를 시드하고 user_id(uuid str)를 돌려준다. + + ★ 예전엔 `company_id`(소속사)였다. 회사(테넌트)를 걷어내면서 사업장이 `owner_user_id` 로 + 계정에 직접 매이게 됐다 — DB 를 직접 시드하는 테스트가 place 에 넣을 주인이 이 값이다. """ - cid = uuid.uuid4() + uid = uuid.uuid4() async with db_engine.begin() as conn: - # status 는 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시. + # status·role 은 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시. await conn.execute( - text("INSERT INTO companies (company_id, name, status) VALUES (:cid, :name, :status)"), - {"cid": cid, "name": "테스트사", "status": CompanyStatus.ACTIVE.value}, + text( + "INSERT INTO users (user_id, id, password, name, status, role, last_accessed_at) " + "VALUES (:uid, :id, NULL, :name, :status, :role, now())" + ), + {"uid": uid, "id": f"seed{uid.hex[:8]}", "name": "시드사장", + "status": UserStatus.ACTIVE.value, "role": UserRole.USER.value}, ) - return str(cid) - - -@pytest_asyncio.fixture -async def other_company_id(db_engine) -> str: - """company_id 와 다른 소속사 1개(회사 스코프/IDOR 격리 테스트용).""" - cid = uuid.uuid4() - async with db_engine.begin() as conn: - await conn.execute( - text("INSERT INTO companies (company_id, name, status) VALUES (:cid, :name, :status)"), - {"cid": cid, "name": "다른회사", "status": CompanyStatus.ACTIVE.value}, - ) - return str(cid) + return str(uid) @pytest_asyncio.fixture @@ -139,28 +133,27 @@ async def client(db_engine): @pytest_asyncio.fixture -async def auth_headers(db_engine, client, company_id): +async def auth_headers(db_engine, client): """테스트 유저를 시드하고 로그인 헤더(Bearer)를 돌려주는 팩토리. 계정 생성 API 가 없으므로 users 행을 직접 INSERT(비번 bcrypt 해시)한 뒤 /v1/auth/login 으로 토큰을 받는다. - company 미지정 시 기본 소속사(company_id 픽스처). role 로 OWNER 계정도 만들 수 있다. - 호출: `h = await auth_headers("user1")` / `await auth_headers("userB", other_company_id)`. + ★ 회사 인자가 없다. 스코프가 계정 자체이므로 **다른 login_id 로 한 번 더 부르면 그게 남**이다 + — 격리 테스트는 `await auth_headers("o2")` 하나면 된다. + 호출: `h = await auth_headers("user1")`. """ - from common.enums import UserRole, UserStatus from router.v1.validator.dependencies import GetHashedPW - async def _make(login_id, company=None, *, password="pw1234", role=UserRole.USER.value, name="n"): - cid = company or company_id + async def _make(login_id, *, password="pw1234", role=UserRole.USER.value, name="n"): hashed = await GetHashedPW(password) async with db_engine.begin() as conn: - # status·role 은 NOT NULL — ORM default 는 raw INSERT 에 안 먹으므로 명시(companies.status 와 동일). + # status·role 은 NOT NULL — ORM default 는 raw INSERT 에 안 먹으므로 명시. await conn.execute( text( - "INSERT INTO users (user_id, company_id, id, password, name, status, role, last_accessed_at) " - "VALUES (:uid, :cid, :id, :pw, :name, :status, :role, now())" + "INSERT INTO users (user_id, id, password, name, status, role, last_accessed_at) " + "VALUES (:uid, :id, :pw, :name, :status, :role, now())" ), { - "uid": uuid.uuid4(), "cid": uuid.UUID(cid), "id": login_id, "pw": hashed, + "uid": uuid.uuid4(), "id": login_id, "pw": hashed, "name": name, "status": UserStatus.ACTIVE.value, "role": role, }, ) diff --git a/solution/backend/crud/place_crud.py b/solution/backend/crud/place_crud.py index 8e75980..70316aa 100644 --- a/solution/backend/crud/place_crud.py +++ b/solution/backend/crud/place_crud.py @@ -11,26 +11,26 @@ from common.logger import LOG from common.utils.gtime import GTime -# 사업장 CRUD. 모든 조회는 company_id(테넌트)로 스코프한다 — 남의 회사 사업장이 보이면 안 된다. +# 사업장 CRUD. 모든 조회는 owner_user_id(사장님)로 스코프한다 — 남의 가게가 보이면 안 된다. class IPlaceCRUD(ABC): @abstractmethod async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType: pass @abstractmethod - async def get_place(self, cdb: AsyncSession, company_id, place_id) -> Tuple[ErrorType, places]: + async def get_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, places]: pass @abstractmethod - async def list_places(self, cdb: AsyncSession, company_id, search, category, status, skip, limit) -> Tuple[ErrorType, list, int]: + async def list_places(self, cdb: AsyncSession, owner_user_id, search, category, status, skip, limit) -> Tuple[ErrorType, list, int]: pass @abstractmethod - async def update_place(self, cdb: AsyncSession, company_id, place_id, data: dict) -> Tuple[ErrorType, int]: + async def update_place(self, cdb: AsyncSession, owner_user_id, place_id, data: dict) -> Tuple[ErrorType, int]: pass @abstractmethod - async def delete_place(self, cdb: AsyncSession, company_id, place_id) -> Tuple[ErrorType, int]: + async def delete_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, int]: pass @abstractmethod @@ -73,11 +73,11 @@ class PlaceCRUD(IPlaceCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED - async def get_place(self, cdb: AsyncSession, company_id, place_id) -> Tuple[ErrorType, places]: + async def get_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, places]: try: query = ( select(places) - .where(places.place_id == place_id, places.company_id == company_id, places.deleted == False) # noqa: E712 + .where(places.place_id == place_id, places.owner_user_id == owner_user_id, places.deleted == False) # noqa: E712 .limit(1) ) err_type, row_list = await DB_SESSION_MNG.execute(cdb, query) @@ -91,11 +91,11 @@ class PlaceCRUD(IPlaceCRUD): return ErrorType.DB_RUN_FAILED, None async def list_places( - self, cdb: AsyncSession, company_id, search: Optional[str], category: Optional[int], + self, cdb: AsyncSession, owner_user_id, search: Optional[str], category: Optional[int], status: Optional[int], skip: int, limit: int, ) -> Tuple[ErrorType, list, int]: try: - conditions = [places.deleted == False, places.company_id == company_id] # noqa: E712 + conditions = [places.deleted == False, places.owner_user_id == owner_user_id] # noqa: E712 if category is not None: conditions.append(places.category == category) if status is not None: @@ -126,14 +126,14 @@ class PlaceCRUD(IPlaceCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, [], 0 - async def update_place(self, cdb: AsyncSession, company_id, place_id, data: dict) -> Tuple[ErrorType, int]: + async def update_place(self, cdb: AsyncSession, owner_user_id, place_id, data: dict) -> Tuple[ErrorType, int]: """회사 스코프를 WHERE 에 걸어 남의 회사 사업장을 못 건드리게 한다. (ErrorType, 적용행수).""" try: if not data: return ErrorType.SUCCESS, 0 query = ( update(places) - .where(places.place_id == place_id, places.company_id == company_id, places.deleted == False) # noqa: E712 + .where(places.place_id == place_id, places.owner_user_id == owner_user_id, places.deleted == False) # noqa: E712 .values(**data, updated_at=GTime.UTC()) ) return await DB_SESSION_MNG.add_with_rowcount(cdb, query) @@ -141,12 +141,12 @@ class PlaceCRUD(IPlaceCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, 0 - async def delete_place(self, cdb: AsyncSession, company_id, place_id) -> Tuple[ErrorType, int]: + async def delete_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, int]: """사업장을 실제 삭제한다. 회사 스코프 밖의 행은 건드리지 않는다.""" try: query = ( delete(places) - .where(places.place_id == place_id, places.company_id == company_id, places.deleted == False) # noqa: E712 + .where(places.place_id == place_id, places.owner_user_id == owner_user_id, places.deleted == False) # noqa: E712 ) return await DB_SESSION_MNG.add_with_rowcount(cdb, query) except Exception as ex: diff --git a/solution/backend/crud/site_crud.py b/solution/backend/crud/site_crud.py index febc075..be7b47a 100644 --- a/solution/backend/crud/site_crud.py +++ b/solution/backend/crud/site_crud.py @@ -22,7 +22,7 @@ class ISiteCRUD(ABC): pass @abstractmethod - async def list_company_sites(self, cdb: AsyncSession, company_id, skip, limit) -> Tuple[ErrorType, list, int]: + async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip, limit) -> Tuple[ErrorType, list, int]: pass @abstractmethod @@ -93,13 +93,13 @@ class SiteCRUD(ISiteCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, None - async def list_company_sites(self, cdb: AsyncSession, company_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]: - """회사의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수). + async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]: + """사장님의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수). 따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장 (위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다.""" try: - where = and_(places.deleted == False, places.company_id == company_id) # noqa: E712 + where = and_(places.deleted == False, places.owner_user_id == owner_user_id) # noqa: E712 cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(places).where(where)) if cnt_err != ErrorType.SUCCESS: diff --git a/solution/backend/crud/user_crud.py b/solution/backend/crud/user_crud.py index 30b22ee..d24088c 100644 --- a/solution/backend/crud/user_crud.py +++ b/solution/backend/crud/user_crud.py @@ -1,12 +1,12 @@ from abc import ABC, abstractmethod -from typing import Optional, Tuple +from typing import Tuple -from sqlalchemy import select, func, and_, or_, update +from sqlalchemy import select, func, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import users, companies -from common.enums import ErrorType, UserRole +from common.database.model.models import users +from common.enums import ErrorType from common.logger import LOG from common.utils.gtime import GTime @@ -39,22 +39,6 @@ class IUserCRUD(ABC): async def update_last_accessed(self, cdb: AsyncSession, user_id) -> ErrorType: pass - @abstractmethod - async def add_company(self, cdb: AsyncSession, company: companies) -> ErrorType: - pass - - @abstractmethod - async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]: - pass - - @abstractmethod - async def update_company_settings(self, cdb: AsyncSession, company_id, settings: dict) -> ErrorType: - pass - - @abstractmethod - async def list_by_company(self, cdb: AsyncSession, company_id, search, skip, limit, hide_dev: bool = False) -> Tuple[ErrorType, list, int]: - pass - @abstractmethod async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]: pass @@ -145,72 +129,6 @@ class UserCRUD(IUserCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED - async def add_company(self, cdb: AsyncSession, company: companies) -> ErrorType: - try: - return await DB_SESSION_MNG.insert(cdb, company) - except Exception as ex: - LOG.e_no_callstack(ex) - return ErrorType.DB_RUN_FAILED - - async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]: - try: - query = select(companies).where(companies.company_id == company_id, companies.deleted == False).limit(1) # noqa: E712 - err_type, row_list = await DB_SESSION_MNG.execute(cdb, query) - if err_type != ErrorType.SUCCESS: - return err_type, None - if len(row_list) != 1: - return ErrorType.DB_INVALID_KEY, None - return ErrorType.SUCCESS, row_list[0] - except Exception as ex: - LOG.e_no_callstack(ex) - return ErrorType.DB_RUN_FAILED, None - - async def update_company_settings(self, cdb: AsyncSession, company_id, settings: dict) -> ErrorType: - try: - query = ( - update(companies) - .where(companies.company_id == company_id, companies.deleted == False) # noqa: E712 - .values(settings=settings, updated_at=GTime.UTC()) - ) - return await DB_SESSION_MNG.add(cdb, query) - except Exception as ex: - LOG.e_no_callstack(ex) - return ErrorType.DB_RUN_FAILED - - async def list_by_company( - self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int, hide_dev: bool = False - ) -> Tuple[ErrorType, list, int]: - try: - conditions = [users.deleted == False, users.company_id == company_id] # noqa: E712 - # 개발자(내부 운영) 계정은 고객사에 존재 자체가 보이면 안 된다 — 목록에서 빼고 총계에도 넣지 않는다. - if hide_dev: - conditions.append(users.role != UserRole.DEVELOPER.value) - if search: - conditions.append( - or_( - users.id.ilike(f"%{search}%"), - users.name.ilike(f"%{search}%"), - users.email.ilike(f"%{search}%"), - ) - ) - where = and_(*conditions) - - cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(users).where(where)) - if cnt_err != ErrorType.SUCCESS: - return cnt_err, [], 0 - total = int(cnt_rows[0] or 0) if cnt_rows else 0 - - list_err, rows = await DB_SESSION_MNG.execute( - cdb, - select(users).where(where).order_by(users.created_at.desc()).offset(skip).limit(limit), - ) - if list_err != ErrorType.SUCCESS: - return list_err, [], 0 - return ErrorType.SUCCESS, list(rows), total - except Exception as ex: - LOG.e_no_callstack(ex) - return ErrorType.DB_RUN_FAILED, [], 0 - async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]: try: query = select(users).where(users.user_id == user_id, users.deleted == False).limit(1) # noqa: E712 diff --git a/solution/backend/router/v1/auth/protocol.py b/solution/backend/router/v1/auth/protocol.py index 99b699c..425e9cf 100644 --- a/solution/backend/router/v1/auth/protocol.py +++ b/solution/backend/router/v1/auth/protocol.py @@ -1,7 +1,5 @@ from typing import Optional -from pydantic import Field - from common.enums import AuthProvider, UserRole from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol @@ -17,7 +15,7 @@ class Req_Login(AuthProtocol): class Req_Signup(AuthProtocol): - """id/pw 가입. 가입 = 새 테넌트(회사) 1개 + 그 회사의 첫 계정 1개다. + """id/pw 가입. 가입 = 계정 1개다. ★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면 한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라 @@ -27,7 +25,6 @@ class Req_Signup(AuthProtocol): password: str = "" name: Optional[str] = None email: str = "" - company_name: Optional[str] = None # 상호. 비우면 이름 → 아이디 순으로 채운다 class Req_GoogleLogin(AuthProtocol): @@ -46,7 +43,7 @@ class Res_Login(Res_WebPacketProtocol): class Req_UpdateMe(AuthProtocol): - # 본인 정보 수정. role·company·id 는 받지 않는다(자기 권한·소속 변경 불가). + # 본인 정보 수정. role·id 는 받지 않는다(자기 권한 변경 불가). name: Optional[str] = None email: Optional[str] = None contact_number: Optional[str] = None @@ -58,11 +55,6 @@ class Res_RefreshToken(Res_WebPacketProtocol): token_type: str = "bearer" -class CompanyData(WebPacketProtocol): - company_id: str = "" - name: str = "" - - class Res_Me(Res_WebPacketProtocol): user_id: str = "" id: str = "" @@ -73,4 +65,3 @@ class Res_Me(Res_WebPacketProtocol): # 이 계정이 무엇으로 로그인하는가. 구글 계정에는 바꿀 비밀번호가 없어서(update_me 가 막는다) # 내 정보 화면이 붙을 때 이 값으로 갈라야 한다. provider: AuthProvider = AuthProvider.LOCAL - company: Optional[CompanyData] = Field(default=None) diff --git a/solution/backend/router/v1/place/place.py b/solution/backend/router/v1/place/place.py index 3b67e4d..67622c9 100644 --- a/solution/backend/router/v1/place/place.py +++ b/solution/backend/router/v1/place/place.py @@ -31,7 +31,7 @@ from .protocol import ( Res_UnitList, ) -# 사업장 라우터. 모든 조회·변경은 토큰의 회사(company_id)로 스코프된다. +# 사업장 라우터. 모든 조회·변경은 토큰의 사장님(places.owner_user_id)으로 스코프된다. router = APIRouter(prefix="/v1/place", tags=["Place"], responses={404: {"description": "Not found"}}) diff --git a/solution/backend/router/v1/place/protocol.py b/solution/backend/router/v1/place/protocol.py index 2fb75ea..7848e36 100644 --- a/solution/backend/router/v1/place/protocol.py +++ b/solution/backend/router/v1/place/protocol.py @@ -18,7 +18,8 @@ class Req_CreatePlace(PlaceProtocol): # 상호명 하나로 시작한다. 나머지는 카카오 로컬 검증이 채운다. name: str = "" category: PlaceCategory = PlaceCategory.LODGING - owner_user_id: Optional[uuid.UUID] = None + # ★ 주인은 받지 않는다 — 토큰이 정한다(place_service.create_place). 여기로 받으면 + # 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다. class Req_VerifyPlace(PlaceProtocol): @@ -58,8 +59,8 @@ class Req_VerifyPlaceByUrl(PlaceProtocol): class Req_UpdatePlace(PlaceProtocol): + # ★ 주인은 못 바꾼다(위 Req_CreatePlace 주석). 소유권 이전은 아직 기능이 아니다. name: Optional[str] = None - owner_user_id: Optional[uuid.UUID] = None status: Optional[PlaceStatus] = None @@ -212,7 +213,7 @@ class Res_VerifyCandidates(Res_WebPacketProtocol): class PlaceSearchItem(WebPacketProtocol): """공개 검색 결과 1건. - ★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·company_id·소유자)은 + ★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·소유자)은 하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다. ★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고, 확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다.""" diff --git a/solution/backend/scripts/demo_build.py b/solution/backend/scripts/demo_build.py index 9a3104c..1c8266b 100644 --- a/solution/backend/scripts/demo_build.py +++ b/solution/backend/scripts/demo_build.py @@ -53,13 +53,11 @@ async def main(): engine = create_async_engine(dsn) from router.v1.validator.dependencies import GetHashedPW - cid, uid, login = uuid.uuid4(), uuid.uuid4(), f"demo{uuid.uuid4().hex[:6]}" + uid, login = uuid.uuid4(), f"demo{uuid.uuid4().hex[:6]}" async with engine.begin() as c: - await c.execute(text("INSERT INTO company.companies (company_id,name,status) VALUES (:c,:n,1)"), - {"c": cid, "n": "데모대행사"}) - await c.execute(text("INSERT INTO company.users (user_id,company_id,id,password,name,status,role,last_accessed_at) " - "VALUES (:u,:c,:i,:p,:n,:s,:r,now())"), - {"u": uid, "c": cid, "i": login, "p": await GetHashedPW("pw1234"), + await c.execute(text("INSERT INTO company.users (user_id,id,password,name,status,role,last_accessed_at) " + "VALUES (:u,:i,:p,:n,:s,:r,now())"), + {"u": uid, "i": login, "p": await GetHashedPW("pw1234"), "n": "데모", "s": UserStatus.ACTIVE.value, "r": UserRole.OWNER.value}) from router.router import app diff --git a/solution/backend/scripts/demo_pipeline.py b/solution/backend/scripts/demo_pipeline.py index 5e26544..29b758e 100644 --- a/solution/backend/scripts/demo_pipeline.py +++ b/solution/backend/scripts/demo_pipeline.py @@ -41,12 +41,11 @@ async def main(): engine = create_async_engine(dsn) from router.v1.validator.dependencies import GetHashedPW - cid, uid, login = uuid.uuid4(), uuid.uuid4(), f"demo{uuid.uuid4().hex[:6]}" + uid, login = uuid.uuid4(), f"demo{uuid.uuid4().hex[:6]}" async with engine.begin() as c: - await c.execute(text("INSERT INTO company.companies (company_id,name,status) VALUES (:c,:n,1)"), {"c": cid, "n": "데모대행사"}) - await c.execute(text("INSERT INTO company.users (user_id,company_id,id,password,name,status,role,last_accessed_at) " - "VALUES (:u,:c,:i,:p,:n,:s,:r,now())"), - {"u": uid, "c": cid, "i": login, "p": await GetHashedPW("pw1234"), + await c.execute(text("INSERT INTO company.users (user_id,id,password,name,status,role,last_accessed_at) " + "VALUES (:u,:i,:p,:n,:s,:r,now())"), + {"u": uid, "i": login, "p": await GetHashedPW("pw1234"), "n": "데모", "s": UserStatus.ACTIVE.value, "r": UserRole.OWNER.value}) await engine.dispose() diff --git a/solution/backend/services/auth_service.py b/solution/backend/services/auth_service.py index 63a59fb..34f6993 100644 --- a/solution/backend/services/auth_service.py +++ b/solution/backend/services/auth_service.py @@ -4,13 +4,12 @@ import uuid from fastapi import Depends from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import companies, users -from common.enums import AuthProvider, CompanyStatus, DBWRType, ErrorType, UserRole, UserStatus +from common.database.model.models import users +from common.enums import AuthProvider, DBWRType, ErrorType, UserRole, UserStatus from common.logger import LOG from common.models.gmodel import UserInfo from crud.user_crud import IUserCRUD, UserCRUD from router.v1.auth.protocol import ( - CompanyData, Req_GoogleLogin, Req_Signup, Req_UpdateMe, @@ -71,11 +70,11 @@ class AuthService: @staticmethod def _user_info(user: users) -> UserInfo: - # uuid → str (JWT json 직렬화 위해). 기능 라우터는 company_id 로 스코프한다. + # uuid → str (JWT json 직렬화 위해). 기능 라우터는 user_id 로 스코프한다 + # — 사업장이 places.owner_user_id 로 이 값에 매여 있다. return UserInfo( user_id=str(user.user_id), id=user.id, - company_id=str(user.company_id), role=user.role, ) @@ -140,26 +139,17 @@ class AuthService: password_hash: str | None, name: str | None, email: str | None, - company_name: str, provider: AuthProvider, provider_uid: str | None, ) -> tuple[ErrorType, users]: - """회사 1개 + 그 회사의 첫 계정 1개를 한 트랜잭션으로 만든다. + """계정 1개를 만든다. - ★ 가입은 곧 새 테넌트다. users.company_id 가 NOT NULL 이고 모든 도메인(사업장·사이트)이 - company_id 로 스코프되므로, 회사 없는 계정은 아무것도 만들지 못한다. - ★ uuid 를 여기서 미리 만든다. 모델 default 는 flush 시점에 적용돼서, 그 전에 - company.company_id 를 읽으면 None 이다 — 그대로 넣으면 NOT NULL 위반이다.""" - company_uuid = uuid.uuid4() - company = companies( - company_id=company_uuid, - name=_fit(company_name, 100), - email=_fit(email, 255), - status=CompanyStatus.ACTIVE.value, - ) + ★ 예전엔 가입 한 번이 **회사(테넌트) 하나**를 같이 만들었고 모든 도메인이 그 회사로 + 스코프됐다. 쓰는 사람은 사장님 혼자인데 자기 회사에 소속된 직원이 되는 구조라 + 걷어냈다(2026-09-08) — 이제 사업장이 `places.owner_user_id` 로 이 계정에 직접 매인다. + ★ uuid 를 여기서 미리 만든다. 모델 default 는 flush 시점에 적용돼서 그 전에 읽으면 None 이다.""" user = users( user_id=uuid.uuid4(), - company_id=company_uuid, id=login_id, password=password_hash, name=_fit(name, 50), @@ -171,10 +161,7 @@ class AuthService: ) err_type = await DB_SESSION_MNG.execute_lambda_run( [users.DBType()], - [ - lambda s: self.user_crud.add_company(s, company), - lambda s: self.user_crud.add_user(s, user), - ], + [lambda s: self.user_crud.add_user(s, user)], ) if err_type != ErrorType.SUCCESS: return err_type, None @@ -226,7 +213,6 @@ class AuthService: password_hash=await GetHashedPW(req.password), name=name, email=email, - company_name=(req.company_name or "").strip() or name or login_id, provider=AuthProvider.LOCAL, provider_uid=None, ) @@ -268,13 +254,12 @@ class AuthService: res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT) return res - # 3) 첫 방문 — 계정과 회사를 만든다. + # 3) 첫 방문 — 계정을 만든다. err_type, user = await self._create_account( login_id=_google_login_id(account.sub), password_hash=None, name=account.name or None, email=account.email or None, - company_name=account.name or account.email or _google_login_id(account.sub), provider=AuthProvider.GOOGLE, provider_uid=account.sub, ) @@ -299,16 +284,6 @@ class AuthService: return res user: users - # 2) 소속사 조회 (없어도 치명적 아님) - company = None - c_err, company_row = await DB_SESSION_MNG.execute_lambda( - users.DBType(), - DBWRType.DB_READ.value, - lambda s: self.user_crud.get_company(s, user.company_id), - ) - if c_err == ErrorType.SUCCESS and company_row is not None: - company = CompanyData(company_id=str(company_row.company_id), name=company_row.name) - res.user_id = str(user.user_id) res.id = user.id res.name = user.name @@ -316,7 +291,6 @@ class AuthService: res.contact_number = user.contact_number res.role = UserRole(user.role) res.provider = AuthProvider(user.provider) - res.company = company return res async def update_me(self, user_info: UserInfo, req: Req_UpdateMe) -> Res_Me: diff --git a/solution/backend/services/collect_service.py b/solution/backend/services/collect_service.py index 8234d26..368cba6 100644 --- a/solution/backend/services/collect_service.py +++ b/solution/backend/services/collect_service.py @@ -431,12 +431,12 @@ async def run_collect(job: dict) -> dict: """COLLECT 잡 핸들러. 반환값이 jobs.result 에 저장돼 폴링·감사에 쓰인다.""" payload = job["payload"] place_id = payload["place_id"] - company_id = payload["company_id"] + owner_user_id = payload["owner_user_id"] err, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)), + lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)), ) if err != ErrorType.SUCCESS or place is None: raise CollectAborted(f"사업장을 찾을 수 없다: {place_id}") @@ -464,7 +464,7 @@ async def run_collect(job: dict) -> dict: if not targets: result["note"] = "크롤링 대상이 없다(어댑터가 처리할 수 있는 확정 URL 0건)" - await _finish(place_id, company_id, PlaceStatus.REVIEW) + await _finish(place_id, owner_user_id, PlaceStatus.REVIEW) return result # ★ 이미 충분하면 크롤링 자체를 건너뛴다(force 가 아닐 때). @@ -472,16 +472,19 @@ async def run_collect(job: dict) -> dict: if before["enough"] and not payload.get("force"): result["coverage"] = before result["note"] = "이미 필수 항목이 다 차 있다 — 크롤링 생략(force=true 로 강제 가능)" - await _finish(place_id, company_id, PlaceStatus.REVIEW) + await _finish(place_id, owner_user_id, PlaceStatus.REVIEW) LOG.i(f"[collect] 충분함 place={place_id} {before['covered']}/{before['total']} — 크롤링 생략") return result from common.models.gmodel import UserInfo actor = UserInfo( - user_id=payload.get("requested_by") or str(place.verified_by or uuid.uuid4()), + # ★ 잡이 쓰는 신원. user_id 는 **사업장 주인**이어야 한다 — FactService 가 이 값으로 + # 사업장을 스코프하고(fact_service._load_place) verified_by 에도 그대로 박는다. + # 회사를 걷어내기 전에는 스코프가 company_id 였고 여기엔 요청자·검증자·랜덤 uuid 가 + # 순서대로 들어갔다. 그 랜덤 uuid 가 이제는 "남의 사업장" 이 되어 조회가 0건이 된다. + user_id=owner_user_id, id="collector", - company_id=company_id, role=1, ) @@ -538,24 +541,24 @@ async def run_collect(job: dict) -> dict: # 사진이 들어왔으면 분석을 이어서 건다 — 수집과 분석은 각각 몇 분이라 한 잡에 묶지 않는다. # (묶으면 분석에서 죽었을 때 수집까지 다시 하게 되고, 유료 API 를 두 번 태운다.) if result["media"]["stored"] > 0: - result["vision_job_id"] = await _enqueue_vision(place_id, company_id) + result["vision_job_id"] = await _enqueue_vision(place_id, owner_user_id) - await _finish(place_id, company_id, PlaceStatus.REVIEW) + await _finish(place_id, owner_user_id, PlaceStatus.REVIEW) LOG.i(f"[collect] 완료 place={place_id} fact {result['facts']['stored']}건 · 사진 {result['media']['stored']}장") return result -async def _finish(place_id: str, company_id: str, status: PlaceStatus): +async def _finish(place_id: str, owner_user_id: str, status: PlaceStatus): """수집이 끝나면 사업장을 검수 대기로 돌린다 — 수집값은 전부 후보라 사람이 봐야 한다.""" await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), lambda s: _place_crud.update_place( - s, uuid.UUID(company_id), uuid.UUID(place_id), {"status": status.value} + s, uuid.UUID(owner_user_id), uuid.UUID(place_id), {"status": status.value} ), ) -async def _enqueue_vision(place_id: str, company_id: str) -> str | None: +async def _enqueue_vision(place_id: str, owner_user_id: str) -> str | None: """사진 분석 잡을 적재한다. 키가 없거나 중복이면 조용히 건너뛴다(수집 자체는 성공이다).""" from common.enums import JobType from crud.job_crud import JobQueue @@ -567,7 +570,7 @@ async def _enqueue_vision(place_id: str, company_id: str) -> str | None: return None job_id, _created = await enqueue_job( JobQueue(), JobType.VISION, - {"place_id": place_id, "company_id": company_id}, + {"place_id": place_id, "owner_user_id": owner_user_id}, dedupe_key=f"vision:{place_id}", ) return job_id diff --git a/solution/backend/services/copy_service.py b/solution/backend/services/copy_service.py index d796553..2869edb 100644 --- a/solution/backend/services/copy_service.py +++ b/solution/backend/services/copy_service.py @@ -40,10 +40,10 @@ class CopyAborted(RuntimeError): async def run_copy(job: dict) -> dict: - """COPY 잡 핸들러. payload: {place_id, company_id, requested_by?}""" + """COPY 잡 핸들러. payload: {place_id, owner_user_id, requested_by?}""" payload = job["payload"] place_id = payload["place_id"] - company_id = payload["company_id"] + owner_user_id = payload["owner_user_id"] if not gemini_text.is_configured(): raise CopyAborted("GEMINI_API_KEY 미설정 — 소개문·FAQ 를 생성할 수 없다") @@ -51,7 +51,7 @@ async def run_copy(job: dict) -> dict: err, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)), + lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)), ) if err != ErrorType.SUCCESS or place is None: raise CopyAborted(f"사업장을 찾을 수 없다: {place_id}") @@ -165,9 +165,12 @@ async def run_copy(job: dict) -> dict: } actor = UserInfo( - user_id=payload.get("requested_by") or str(place.verified_by or uuid.uuid4()), + # ★ 잡이 쓰는 신원. user_id 는 **사업장 주인**이어야 한다 — FactService 가 이 값으로 + # 사업장을 스코프하고(fact_service._load_place) verified_by 에도 그대로 박는다. + # 회사를 걷어내기 전에는 스코프가 company_id 였고 여기엔 요청자·검증자·랜덤 uuid 가 + # 순서대로 들어갔다. 그 랜덤 uuid 가 이제는 "남의 사업장" 이 되어 조회가 0건이 된다. + user_id=owner_user_id, id="generator", - company_id=company_id, role=1, ) service = FactService(_fact_crud, _place_crud) diff --git a/solution/backend/services/fact_service.py b/solution/backend/services/fact_service.py index cfd8b8b..c22ded9 100644 --- a/solution/backend/services/fact_service.py +++ b/solution/backend/services/fact_service.py @@ -73,7 +73,7 @@ class FactService: err_type, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)), + lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)), ) if err_type != ErrorType.SUCCESS: return ErrorType.PLACE_NOT_FOUND, None diff --git a/solution/backend/services/faq_service.py b/solution/backend/services/faq_service.py index c4c2efa..ccb2020 100644 --- a/solution/backend/services/faq_service.py +++ b/solution/backend/services/faq_service.py @@ -38,14 +38,14 @@ class FaqService: self.crud = crud self.place_crud = place_crud - # ---- 사업장 로드(회사 스코프) ---- + # ---- 사업장 로드(사장님 스코프) ---- async def _load_place(self, user_info: UserInfo, place_id: str): - """company_id 를 WHERE 에 걸어 조회한다 — 남의 회사 place_id 를 넣으면 PLACE_NOT_FOUND. + """owner_user_id 를 WHERE 에 걸어 조회한다 — 남의 place_id 를 넣으면 PLACE_NOT_FOUND. '없다'와 '권한 없다'를 구분해 주지 않는 것도 의도다(존재 여부를 흘리지 않는다).""" err_type, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)), + lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)), ) if err_type != ErrorType.SUCCESS: return ErrorType.PLACE_NOT_FOUND, None diff --git a/solution/backend/services/media_service.py b/solution/backend/services/media_service.py index 9a43007..b423ca6 100644 --- a/solution/backend/services/media_service.py +++ b/solution/backend/services/media_service.py @@ -41,7 +41,7 @@ class MediaService: err_type, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)), + lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)), ) if err_type != ErrorType.SUCCESS: return ErrorType.PLACE_NOT_FOUND, None diff --git a/solution/backend/services/place_service.py b/solution/backend/services/place_service.py index ef78d95..9c7d7c5 100644 --- a/solution/backend/services/place_service.py +++ b/solution/backend/services/place_service.py @@ -70,12 +70,12 @@ class PlaceService: # ---- 조회 ---- async def list_places(self, user_info: UserInfo, pg: PageParams, search=None, category=None, status=None) -> Res_PlaceList: res = Res_PlaceList(page=pg.page, size=pg.size) - cid = uuid.UUID(user_info.company_id) + uid = uuid.UUID(user_info.user_id) err_type, rows, total = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list_places( - s, cid, search, + s, uid, search, category.value if isinstance(category, PlaceCategory) else category, status.value if isinstance(status, PlaceStatus) else status, pg.skip, pg.size, @@ -102,7 +102,7 @@ class PlaceService: err_type, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: self.crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)), + lambda s: self.crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)), ) if err_type != ErrorType.SUCCESS: return ErrorType.PLACE_NOT_FOUND, None @@ -122,8 +122,11 @@ class PlaceService: return res place = places( - company_id=uuid.UUID(user_info.company_id), - owner_user_id=req.owner_user_id, + # ★ 주인은 **토큰이 정한다.** 예전엔 요청 body 의 owner_user_id 를 그대로 넣었는데, + # 그 값은 아무도 안 보내서 92건 전부 NULL 이었고 스코프는 회사가 대신 하고 있었다. + # 회사를 걷어내면서 이 컬럼이 스코프 키가 됐다 — body 로 남의 계정을 적을 수 있으면 + # 만들자마자 남의 목록에 들어간다. + owner_user_id=uuid.UUID(user_info.user_id), name=req.name.strip(), category=req.category.value, status=PlaceStatus.DRAFT.value, @@ -149,7 +152,7 @@ class PlaceService: if data: err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), - lambda s: self.crud.update_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id), data), + lambda s: self.crud.update_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id), data), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) @@ -164,7 +167,7 @@ class PlaceService: err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), lambda s: self.crud.delete_place( - s, uuid.UUID(user_info.company_id), uuid.UUID(place_id) + s, uuid.UUID(user_info.user_id), uuid.UUID(place_id) ), ) if err_type != ErrorType.SUCCESS: @@ -251,7 +254,7 @@ class PlaceService: await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), lambda s: self.crud.update_place( - s, uuid.UUID(user_info.company_id), uuid.UUID(place_id), {"name": official} + s, uuid.UUID(user_info.user_id), uuid.UUID(place_id), {"name": official} ), ) if verified.place: @@ -292,7 +295,7 @@ class PlaceService: res.result.SetResult(err_type) return res - cid = uuid.UUID(user_info.company_id) + uid = uuid.UUID(user_info.user_id) now = GTime.UTC() data = { "external_source": req.source.value, @@ -320,7 +323,7 @@ class PlaceService: } err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), - lambda s: self.crud.update_place(s, cid, uuid.UUID(place_id), data), + lambda s: self.crud.update_place(s, uid, uuid.UUID(place_id), data), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) @@ -508,7 +511,7 @@ class PlaceService: payload = { "place_id": place_id, - "company_id": user_info.company_id, + "owner_user_id": user_info.user_id, "category": place.category, # 명시적으로 고른 링크가 있을 때만 대상을 제한한다. 기본 요청에서 현재 # 확정 링크를 복사하면, 잡의 discover 단계가 새로 확정한 네이버 링크가 @@ -537,7 +540,7 @@ class PlaceService: await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), lambda s: self.crud.update_place( - s, uuid.UUID(user_info.company_id), uuid.UUID(place_id), + s, uuid.UUID(user_info.user_id), uuid.UUID(place_id), {"status": PlaceStatus.COLLECTING.value}, ), ) @@ -584,7 +587,7 @@ class PlaceService: job_id, created = await enqueue_job( self.queue, JobType.VISION, - {"place_id": place_id, "company_id": user_info.company_id, "force": req.force}, + {"place_id": place_id, "owner_user_id": user_info.user_id, "force": req.force}, dedupe_key=f"vision:{place_id}", ) if job_id is None: @@ -844,7 +847,7 @@ class PlaceService: job_id, created = await enqueue_job( self.queue, JobType.COPY, - {"place_id": place_id, "company_id": user_info.company_id, "requested_by": user_info.user_id}, + {"place_id": place_id, "owner_user_id": user_info.user_id, "requested_by": user_info.user_id}, dedupe_key=f"copy:{place_id}", ) if job_id is None: diff --git a/solution/backend/services/showcase_service.py b/solution/backend/services/showcase_service.py index 4f058ed..835ef8f 100644 --- a/solution/backend/services/showcase_service.py +++ b/solution/backend/services/showcase_service.py @@ -5,7 +5,7 @@ 고르는 자리를 한 곳으로 모았다. 사이트 한 곳을 여는 것과 발행 업소 명단을 통째로 긁는 것은 다른 일이라, 페이지에 이미 적혀 있는 것만 나간다. - 나가지 않는 것: place_id · company_id · site_id · 전화번호 · 상세 주소 · 좌표. + 나가지 않는 것: place_id · 소유자 · site_id · 전화번호 · 상세 주소 · 좌표. """ from common.database.db_session_manager import DB_SESSION_MNG diff --git a/solution/backend/services/vision_service.py b/solution/backend/services/vision_service.py index 9f7e4a8..0b1e534 100644 --- a/solution/backend/services/vision_service.py +++ b/solution/backend/services/vision_service.py @@ -28,10 +28,10 @@ class VisionAborted(RuntimeError): async def run_vision(job: dict) -> dict: - """VISION 잡 핸들러. payload: {place_id, company_id, force?}""" + """VISION 잡 핸들러. payload: {place_id, owner_user_id, force?}""" payload = job["payload"] place_id = payload["place_id"] - company_id = payload["company_id"] + owner_user_id = payload["owner_user_id"] force = bool(payload.get("force")) if not gemini.is_configured(): @@ -40,7 +40,7 @@ async def run_vision(job: dict) -> dict: err, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)), + lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)), ) if err != ErrorType.SUCCESS or place is None: raise VisionAborted(f"사업장을 찾을 수 없다: {place_id}") diff --git a/solution/backend/tests/test_auth.py b/solution/backend/tests/test_auth.py index c596f4a..a3b0a84 100644 --- a/solution/backend/tests/test_auth.py +++ b/solution/backend/tests/test_auth.py @@ -4,9 +4,9 @@ """ -async def test_login_and_me_flow(auth_headers, client, company_id): +async def test_login_and_me_flow(auth_headers, client): """검증: 시드된 유저가 로그인해 받은 토큰으로 /me 호출. - 기대결과: 200, 본인 id·name·소속사(company_id)가 그대로 반환.""" + 기대결과: 200, 본인 id·name 이 그대로 반환.""" h = await auth_headers("user1", name="홍길동") r = await client.get("/v1/auth/me", headers=h) @@ -14,7 +14,8 @@ async def test_login_and_me_flow(auth_headers, client, company_id): me = r.json() assert me["id"] == "user1" assert me["name"] == "홍길동" - assert me["company"]["company_id"] == company_id + # ★ 소속사 필드는 없다. 회사(테넌트)를 걷어냈다(2026-09-08) — 쓰는 사람은 사장님 혼자다. + assert "company" not in me async def test_login_with_wrong_password(auth_headers, client): @@ -49,7 +50,7 @@ _SIGNUP = {"id": "sajang1", "password": "pw12345678", "name": "김사장", "emai async def test_signup_creates_account_and_logs_in(client, db_engine): """검증: 가입 → 받은 토큰으로 곧바로 /me. - 기대결과: 토큰이 실려 오고, /me 가 방금 만든 신원과 **새로 생긴 소속사**를 돌려준다.""" + 기대결과: 토큰이 실려 오고, /me 가 방금 만든 신원을 돌려준다.""" r = await client.post("/v1/auth/signup", json=_SIGNUP) body = r.json() assert body["result"]["success"] is True @@ -59,7 +60,6 @@ async def test_signup_creates_account_and_logs_in(client, db_engine): assert me["id"] == "sajang1" assert me["email"] == "boss@example.com" assert me["provider"] == 1 # AuthProvider.LOCAL - assert me["company"]["name"] == "김사장" # 회사명 미입력 → 이름으로 채운다 async def test_signup_rejects_duplicate_id(client, db_engine): diff --git a/solution/backend/tests/test_build_publish.py b/solution/backend/tests/test_build_publish.py index 9aab5b2..87c5f78 100644 --- a/solution/backend/tests/test_build_publish.py +++ b/solution/backend/tests/test_build_publish.py @@ -264,12 +264,12 @@ async def test_versions_accumulate(auth_headers, client, db_engine): assert [v["version"] for v in versions] == [2, 1] -async def test_site_is_scoped_to_company(auth_headers, client, other_company_id): - """검증: 다른 회사 계정으로 남의 사이트를 본다. +async def test_site_is_scoped_to_owner(auth_headers, client): + """검증: 다른 사장님 계정으로 남의 사이트를 본다. 기대결과: PLACE_NOT_FOUND.""" h1 = await auth_headers("o1") pid = await _place(client, h1, "스코프펜션") - h2 = await auth_headers("o2", other_company_id) + h2 = await auth_headers("o2") r = await client.get(f"/v1/place/{pid}/site", headers=h2) assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value diff --git a/solution/backend/tests/test_collect_api.py b/solution/backend/tests/test_collect_api.py index c2716e2..205a3da 100644 --- a/solution/backend/tests/test_collect_api.py +++ b/solution/backend/tests/test_collect_api.py @@ -156,13 +156,13 @@ async def test_targeted_collect_payload_carries_requested_confirmed_link(auth_he assert job["payload"]["link_ids"] == [confirmed] -async def test_collect_is_scoped_to_company(auth_headers, client, other_company_id): - """검증: 다른 회사 계정으로 남의 사업장 수집을 시작한다. +async def test_collect_is_scoped_to_owner(auth_headers, client): + """검증: 다른 사장님 계정으로 남의 사업장 수집을 시작한다. 기대결과: PLACE_NOT_FOUND — 사업장이 안 보이니 수집도 못 건다.""" h1 = await auth_headers("o1") pid = await _place(client, h1, kakao="c7") await _confirmed_link(client, h1, pid) - h2 = await auth_headers("o2", other_company_id) + h2 = await auth_headers("o2") r = await client.post(f"/v1/place/{pid}/collect", headers=h2, json={}) assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value diff --git a/solution/backend/tests/test_collect_pipeline.py b/solution/backend/tests/test_collect_pipeline.py index 4b5d9d7..5ca4391 100644 --- a/solution/backend/tests/test_collect_pipeline.py +++ b/solution/backend/tests/test_collect_pipeline.py @@ -208,7 +208,7 @@ async def test_recollect_cannot_overwrite_corrected_value(auth_headers, client): assert check_in[0]["status"] == FactStatus.CORRECTED.value -async def test_pipeline_refuses_unverified_place(db_engine, company_id): +async def test_pipeline_refuses_unverified_place(db_engine, owner_id): """검증: 검증 안 된 사업장의 수집 잡이 큐에 직접 들어간 경우(잡 적재 후 검증이 취소된 상황). 기대결과: 잡이 실패한다 — ★ 잡 실행 시점에도 게이트를 다시 확인한다.""" from sqlalchemy import text @@ -216,13 +216,13 @@ async def test_pipeline_refuses_unverified_place(db_engine, company_id): pid = uuid.uuid4() async with db_engine.begin() as conn: await conn.execute( - text("INSERT INTO places (place_id, company_id, name, category, status) " + text("INSERT INTO places (place_id, owner_user_id, name, category, status) " "VALUES (:pid, :cid, :n, 1, 1)"), - {"pid": pid, "cid": uuid.UUID(company_id), "n": "미검증펜션"}, + {"pid": pid, "cid": uuid.UUID(owner_id), "n": "미검증펜션"}, ) q = JobQueue() - job_id = await q.enqueue(JobType.COLLECT.value, {"place_id": str(pid), "company_id": company_id}, max_attempts=1) + job_id = await q.enqueue(JobType.COLLECT.value, {"place_id": str(pid), "owner_user_id": owner_id}, max_attempts=1) worker = Worker("test-worker", q, build_handler(), backoff_fn=lambda _a: 0) await worker.process_one() diff --git a/solution/backend/tests/test_fact_api.py b/solution/backend/tests/test_fact_api.py index 91a0bfc..38f4e0c 100644 --- a/solution/backend/tests/test_fact_api.py +++ b/solution/backend/tests/test_fact_api.py @@ -269,12 +269,12 @@ async def test_crawl_only_does_not_mark_rebuild(auth_headers, client): assert place.get("content_updated_at") is None -async def test_facts_are_scoped_to_company(auth_headers, client, other_company_id): - """검증: 다른 회사 계정으로 남의 사업장 fact 를 조회한다. +async def test_facts_are_scoped_to_owner(auth_headers, client): + """검증: 다른 사장님 계정으로 남의 사업장 fact 를 조회한다. 기대결과: PLACE_NOT_FOUND — 사업장이 안 보이니 fact 도 안 보인다.""" h1 = await auth_headers("o1") pid = await _verified_place(client, h1, kakao="p6") - h2 = await auth_headers("o2", other_company_id) + h2 = await auth_headers("o2") r = await client.get(f"/v1/place/{pid}/fact/list", headers=h2) assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value diff --git a/solution/backend/tests/test_fact_schema.py b/solution/backend/tests/test_fact_schema.py index fe04ef9..a357071 100644 --- a/solution/backend/tests/test_fact_schema.py +++ b/solution/backend/tests/test_fact_schema.py @@ -22,17 +22,17 @@ from common.enums import ( ) -async def _seed_place(db_engine, company_id) -> str: +async def _seed_place(db_engine, owner_id) -> str: """검증까지 끝난 사업장 1개를 시드하고 place_id 를 돌려준다.""" pid = uuid.uuid4() async with db_engine.begin() as conn: await conn.execute( text( - "INSERT INTO places (place_id, company_id, name, category, status, external_place_id, verified_at) " + "INSERT INTO places (place_id, owner_user_id, name, category, status, external_place_id, verified_at) " "VALUES (:pid, :cid, :name, :cat, :status, :kakao, now())" ), { - "pid": pid, "cid": uuid.UUID(company_id), "name": "테스트펜션", + "pid": pid, "cid": uuid.UUID(owner_id), "name": "테스트펜션", "cat": PlaceCategory.LODGING.value, "status": PlaceStatus.DRAFT.value, "kakao": "12345678", }, @@ -54,20 +54,20 @@ async def _insert_fact(db_engine, place_id, key, value, status, unit_id=None): ) -async def test_published_fact_is_unique_per_place_and_key(db_engine, company_id): +async def test_published_fact_is_unique_per_place_and_key(db_engine, owner_id): """검증: 같은 사업장·같은 key 로 노출 상태 fact 를 두 번 넣는다. 기대결과: 두 번째 INSERT 가 유니크 인덱스에 막힌다(체크인 시간이 두 값으로 갈라지지 않는다).""" - place_id = await _seed_place(db_engine, company_id) + place_id = await _seed_place(db_engine, owner_id) await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.VERIFIED) with pytest.raises(IntegrityError): await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.CORRECTED) -async def test_candidates_coexist_with_published_value(db_engine, company_id): +async def test_candidates_coexist_with_published_value(db_engine, owner_id): """검증: 노출값이 있는 상태에서 재수집 후보를 여러 건 넣는다. 기대결과: 전부 공존한다 — ★ 재수집이 노출 중인 사실을 밀어내지 않는다.""" - place_id = await _seed_place(db_engine, company_id) + place_id = await _seed_place(db_engine, owner_id) await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.VERIFIED) await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.PENDING_OWNER) await _insert_fact(db_engine, place_id, "check_in_time", "14:00", FactStatus.UNVERIFIED) @@ -80,10 +80,10 @@ async def test_candidates_coexist_with_published_value(db_engine, company_id): assert len(rows) == 3, "노출값 1건 + 후보 2건이 공존해야 한다" -async def test_rejected_fact_frees_the_key(db_engine, company_id): +async def test_rejected_fact_frees_the_key(db_engine, owner_id): """검증: 기존 값을 REJECTED 로 내린 뒤 같은 key 를 새로 노출한다. 기대결과: 통과 — 틀린 값은 이력으로 남고, 새 값이 노출 자리를 차지한다.""" - place_id = await _seed_place(db_engine, company_id) + place_id = await _seed_place(db_engine, owner_id) await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.REJECTED) await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.VERIFIED) @@ -95,18 +95,18 @@ async def test_rejected_fact_frees_the_key(db_engine, company_id): assert len(rows) == 2, "REJECTED 이력과 새 값이 함께 남아야 한다" -async def test_expired_fact_frees_the_key(db_engine, company_id): +async def test_expired_fact_frees_the_key(db_engine, owner_id): """검증: 유효기간이 지나 EXPIRED 로 내린 값과 새 수집값의 공존. 기대결과: 통과 — EXPIRED 도 유니크에서 빠진다.""" - place_id = await _seed_place(db_engine, company_id) + place_id = await _seed_place(db_engine, owner_id) await _insert_fact(db_engine, place_id, "cancel_policy", "구 규정", FactStatus.EXPIRED) await _insert_fact(db_engine, place_id, "cancel_policy", "새 규정", FactStatus.VERIFIED) -async def test_same_key_allowed_across_units(db_engine, company_id): +async def test_same_key_allowed_across_units(db_engine, owner_id): """검증: 객실이 다르면 같은 key 를 각각 가질 수 있는지. 기대결과: 통과 — A동·B동이 각자의 기준 인원을 갖는다.""" - place_id = await _seed_place(db_engine, company_id) + place_id = await _seed_place(db_engine, owner_id) unit_a, unit_b = uuid.uuid4(), uuid.uuid4() async with db_engine.begin() as conn: for uid, name in ((unit_a, "A동"), (unit_b, "B동")): @@ -122,10 +122,10 @@ async def test_same_key_allowed_across_units(db_engine, company_id): await _insert_fact(db_engine, place_id, "standard_capacity", "6", FactStatus.CORRECTED, unit_id=unit_a) -async def test_unit_fact_and_place_fact_are_separate(db_engine, company_id): +async def test_unit_fact_and_place_fact_are_separate(db_engine, owner_id): """검증: 같은 key 를 사업장 단위와 객실 단위로 동시에 갖는 경우. 기대결과: 통과 — 부분 인덱스가 unit_id NULL 여부로 갈라져 있다.""" - place_id = await _seed_place(db_engine, company_id) + place_id = await _seed_place(db_engine, owner_id) unit_id = uuid.uuid4() async with db_engine.begin() as conn: await conn.execute( diff --git a/solution/backend/tests/test_faq_api.py b/solution/backend/tests/test_faq_api.py index aa79abb..6adda5c 100644 --- a/solution/backend/tests/test_faq_api.py +++ b/solution/backend/tests/test_faq_api.py @@ -56,14 +56,14 @@ async def test_generated_faq_is_pending_and_not_publishable(auth_headers, client assert (await _list(client, h, pid, publishable_only=True)).get("faqs", []) == [] -async def test_other_company_cannot_read_or_touch_faq(auth_headers, client, db_engine, other_company_id): - """검증: 남의 회사 사용자가 place_id 를 알아내 FAQ 를 조회·전이한다. +async def test_other_owner_cannot_read_or_touch_faq(auth_headers, client, db_engine): + """검증: 남의 사용자가 place_id 를 알아내 FAQ 를 조회·전이한다. 기대결과: PLACE_NOT_FOUND — 존재 여부조차 알려주지 않는다.""" h = await auth_headers("u1") pid = await _place(client, h) fid = await _seed_generated_faq(db_engine, pid) - other = await auth_headers("u2", other_company_id) + other = await auth_headers("u2") assert (await _list(client, other, pid))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value body = await _transition(client, other, pid, fid, {"status": FactStatus.VERIFIED.value}) assert body["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value diff --git a/solution/backend/tests/test_my_sites.py b/solution/backend/tests/test_my_sites.py index 3ad532b..c410545 100644 --- a/solution/backend/tests/test_my_sites.py +++ b/solution/backend/tests/test_my_sites.py @@ -3,7 +3,7 @@ 이 경로가 절대 하면 안 되는 것: - 사이트가 아직 없는 사업장을 빼는 것 — 위저드를 걸어오다 만 가게가 목록에서 사라지면 사장님은 그걸 다시 찾을 길이 없다(에디터 주소를 아무도 기억하지 않는다). - - 회사 스코프를 놓치는 것 — 남의 가게가 내 목록에 섞이면 그건 목록이 아니라 사고다. + - 사장님 스코프를 놓치는 것 — 남의 가게가 내 목록에 섞이면 그건 목록이 아니라 사고다. - 단건(GET /v1/place/{id}/site)과 다른 재빌드 판정을 내는 것 — 목록과 에디터가 서로 다른 답을 하면 사장님은 어느 쪽을 믿을지 알 수 없다. """ @@ -53,11 +53,11 @@ async def test_site_row_is_joined_into_the_line(auth_headers, client): assert row["status"] == SiteStatus.DRAFT.value -async def test_other_company_sites_are_not_listed(auth_headers, client, other_company_id): - """검증: 회사(테넌트) 스코프. 남의 회사 사업장은 보이지 않는다. +async def test_other_owners_sites_are_not_listed(auth_headers, client): + """검증: 사장님 스코프. 남의 사업장은 보이지 않는다. 기대결과: 각자 자기 것만 1건.""" mine = await auth_headers("my3") - theirs = await auth_headers("my3b", other_company_id) + theirs = await auth_headers("my3b") await _place(client, mine, "내펜션") await _place(client, theirs, "남의펜션") diff --git a/solution/backend/tests/test_place.py b/solution/backend/tests/test_place.py index 8b74e4d..9c080ed 100644 --- a/solution/backend/tests/test_place.py +++ b/solution/backend/tests/test_place.py @@ -1,8 +1,8 @@ -"""places 도메인 e2e — 등록 / 동일 업소 검증 / 회사 스코프 / 채널 URL 확정 게이트. +"""places 도메인 e2e — 등록 / 동일 업소 검증 / 사장님 스코프 / 채널 URL 확정 게이트. ★ 이 도메인의 핵심 규칙 두 개를 고정한다: 1. 검증(verify) 전에는 채널 URL 을 확정할 수 없다 → 크롤링이 안 열린다 - 2. 남의 회사 사업장은 '없음'으로 보인다 + 2. 남의 사업장은 '없음'으로 보인다 """ import uuid @@ -61,7 +61,7 @@ async def test_verify_place_opens_collection(auth_headers, client): async def test_duplicate_kakao_place_is_allowed(auth_headers, client): - """검증: 같은 회사에서 같은 카카오 장소를 두 사업장에 붙인다. + """검증: 같은 사장님이 같은 카카오 장소를 두 사업장에 붙인다. 기대결과: 둘 다 등록된다 — 한 사용자가 같은 실제 업장으로 여러 프로젝트를 만들 수 있다.""" h = await auth_headers("u1") first = (await _create_place(client, h, "A펜션"))["place"]["place_id"] @@ -138,13 +138,13 @@ async def test_verify_without_any_identifier_is_rejected(auth_headers, client): assert r.json()["result"]["code"] == ErrorType.PLACE_VERIFY_NO_CANDIDATE.value -async def test_place_is_scoped_to_company(auth_headers, client, other_company_id): - """검증: 다른 회사 계정으로 남의 사업장을 조회한다. +async def test_place_is_scoped_to_owner(auth_headers, client): + """검증: 다른 사장님 계정으로 남의 사업장을 조회한다. 기대결과: PLACE_NOT_FOUND — 존재 자체가 보이지 않는다(IDOR 차단).""" h1 = await auth_headers("owner1") pid = (await _create_place(client, h1))["place"]["place_id"] - h2 = await auth_headers("owner2", other_company_id) + h2 = await auth_headers("owner2") r = await client.get(f"/v1/place/{pid}", headers=h2) assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value diff --git a/solution/backend/tests/test_place_search.py b/solution/backend/tests/test_place_search.py index 5e140f7..fb98581 100644 --- a/solution/backend/tests/test_place_search.py +++ b/solution/backend/tests/test_place_search.py @@ -80,7 +80,7 @@ async def test_search_leaks_nothing_of_ours(client, monkeypatch): item = (await client.get("/v1/place/search", params={"q": "하조대펜션"})).json()["items"][0] - for leaked in ("place_id", "company_id", "owner_user_id", "phone", "latitude", "longitude", + for leaked in ("place_id", "owner_user_id", "phone", "latitude", "longitude", "external_place_id", "address"): assert leaked not in item, f"{leaked} 가 공개 응답에 나갔다" diff --git a/solution/backend/tests/test_seo_audit.py b/solution/backend/tests/test_seo_audit.py index d880ebc..512a211 100644 --- a/solution/backend/tests/test_seo_audit.py +++ b/solution/backend/tests/test_seo_audit.py @@ -31,7 +31,7 @@ def test_empty_site_returns_actionable_failures(): assert any(c["status"] == "fail" and c["recommendation"] for c in report["checks"]) -async def test_audit_api_is_company_scoped(auth_headers, client, other_company_id): +async def test_audit_api_is_owner_scoped(auth_headers, client): h1 = await auth_headers("audit-owner") pid = (await client.post("/v1/place", headers=h1, json={"name": "진단가게", "category": 2})).json()["place"]["place_id"] @@ -41,6 +41,6 @@ async def test_audit_api_is_company_scoped(auth_headers, client, other_company_i assert 0 <= own["aeo_score"] <= 100 assert own["checks"] - h2 = await auth_headers("audit-other", other_company_id) + h2 = await auth_headers("audit-other") denied = (await client.get(f"/v1/place/{pid}/site/audit", headers=h2)).json() assert denied["result"]["success"] is False diff --git a/solution/backend/tests/test_showcase_api.py b/solution/backend/tests/test_showcase_api.py index e3f7c9b..331240b 100644 --- a/solution/backend/tests/test_showcase_api.py +++ b/solution/backend/tests/test_showcase_api.py @@ -13,14 +13,14 @@ from sqlalchemy import text from common.enums import PlaceCategory, PlaceStatus, SiteStatus -async def _publish(db_engine, company_id, name, *, status, domain, thumb=None, minutes_ago=0): +async def _publish(db_engine, owner_id, name, *, status, domain, thumb=None, minutes_ago=0): """places + sites 를 직접 넣는다 — 여기서 보는 건 목록 조회지 빌드 파이프라인이 아니다.""" pid, sid = uuid.uuid4(), uuid.uuid4() async with db_engine.begin() as c: await c.execute( - text("INSERT INTO places (place_id, company_id, name, category, status, road_address, address, phone) " + text("INSERT INTO places (place_id, owner_user_id, name, category, status, road_address, address, phone) " "VALUES (:p,:c,:n,:cat,:st,:road,:addr,:phone)"), - {"p": pid, "c": uuid.UUID(company_id), "n": name, "cat": PlaceCategory.LODGING.value, + {"p": pid, "c": uuid.UUID(owner_id), "n": name, "cat": PlaceCategory.LODGING.value, "st": PlaceStatus.PUBLISHED.value, "road": "강원특별자치도 양양군 현북면 하조대3길 12-3", "addr": "강원특별자치도 양양군 현북면 하광정리 1-2", "phone": "033-672-0000"}, @@ -34,12 +34,12 @@ async def _publish(db_engine, company_id, name, *, status, domain, thumb=None, m return str(pid) -async def test_발행된_사이트만_로그인_없이_보인다(client, db_engine, company_id): +async def test_발행된_사이트만_로그인_없이_보인다(client, db_engine, owner_id): """검증: 발행본 1개 + 미발행(DRAFT) 1개를 두고 인증 헤더 없이 부른다. 기대결과: 발행본만 나온다.""" - await _publish(db_engine, company_id, "하조대펜션", status=SiteStatus.PUBLISHED.value, + await _publish(db_engine, owner_id, "하조대펜션", status=SiteStatus.PUBLISHED.value, domain="hajodae", thumb="https://w4ai.o2o.kr/thumbs/hajodae.jpg") - await _publish(db_engine, company_id, "아직펜션", status=SiteStatus.DRAFT.value, domain="notyet") + await _publish(db_engine, owner_id, "아직펜션", status=SiteStatus.DRAFT.value, domain="notyet") res = await client.get("/v1/showcase") @@ -51,10 +51,10 @@ async def test_발행된_사이트만_로그인_없이_보인다(client, db_engi assert items[0]["category"] == PlaceCategory.LODGING.value -async def test_개인정보와_내부값은_나가지_않는다(client, db_engine, company_id): +async def test_개인정보와_내부값은_나가지_않는다(client, db_engine, owner_id): """검증: 응답 항목의 키를 그대로 본다. 기대결과: 상호명·업종·지역·주소·썸네일뿐. 지역은 시·군까지고 상세 주소는 없다.""" - await _publish(db_engine, company_id, "하조대펜션", status=SiteStatus.PUBLISHED.value, domain="hajodae") + await _publish(db_engine, owner_id, "하조대펜션", status=SiteStatus.PUBLISHED.value, domain="hajodae") item = (await client.get("/v1/showcase")).json()["items"][0] @@ -66,10 +66,10 @@ async def test_개인정보와_내부값은_나가지_않는다(client, db_engin assert "하조대3길" not in body -async def test_썸네일이_없으면_키가_없다(client, db_engine, company_id): +async def test_썸네일이_없으면_키가_없다(client, db_engine, owner_id): """★ 썸네일은 발행의 부수 효과라 실패할 수 있다(대표 사진이 없거나 CDN 이 죽었거나). 그때 카드는 글자로 떨어져야지 목록에서 사라지면 안 된다.""" - await _publish(db_engine, company_id, "그림없는집", status=SiteStatus.PUBLISHED.value, domain="nopic") + await _publish(db_engine, owner_id, "그림없는집", status=SiteStatus.PUBLISHED.value, domain="nopic") item = (await client.get("/v1/showcase")).json()["items"][0] @@ -77,9 +77,9 @@ async def test_썸네일이_없으면_키가_없다(client, db_engine, company_i assert "thumbnail_url" not in item -async def test_최신_발행순이고_limit_로_자른다(client, db_engine, company_id): - await _publish(db_engine, company_id, "먼저", status=SiteStatus.PUBLISHED.value, domain="first", minutes_ago=60) - await _publish(db_engine, company_id, "나중", status=SiteStatus.PUBLISHED.value, domain="second", minutes_ago=1) +async def test_최신_발행순이고_limit_로_자른다(client, db_engine, owner_id): + await _publish(db_engine, owner_id, "먼저", status=SiteStatus.PUBLISHED.value, domain="first", minutes_ago=60) + await _publish(db_engine, owner_id, "나중", status=SiteStatus.PUBLISHED.value, domain="second", minutes_ago=1) items = (await client.get("/v1/showcase")).json()["items"] assert [i["name"] for i in items] == ["나중", "먼저"] diff --git a/solution/backend/tests/test_site_slug.py b/solution/backend/tests/test_site_slug.py index 0c78dd5..a589d0b 100644 --- a/solution/backend/tests/test_site_slug.py +++ b/solution/backend/tests/test_site_slug.py @@ -129,11 +129,11 @@ async def test_published_site_slug_is_locked(auth_headers, client, db_engine): assert same["site"]["domain"] == "published-stay" -async def test_other_company_place_is_blocked(auth_headers, client, other_company_id): - """검증: 남의 회사 사업장 주소는 확인도 예약도 못 한다. +async def test_other_owners_place_is_blocked(auth_headers, client): + """검증: 남의 사업장 주소는 확인도 예약도 못 한다. 기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다).""" h = await auth_headers("slug5") - intruder = await auth_headers("slug6", other_company_id) + intruder = await auth_headers("slug6") pid = await _place(client, h) assert (await _check(client, intruder, pid, "doflo"))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value diff --git a/solution/backend/tests/test_site_template.py b/solution/backend/tests/test_site_template.py index 2e87685..fa643e8 100644 --- a/solution/backend/tests/test_site_template.py +++ b/solution/backend/tests/test_site_template.py @@ -99,11 +99,11 @@ async def test_published_site_template_is_not_locked(auth_headers, client, db_en assert changed["needs_rebuild"] is True -async def test_other_company_place_is_blocked(auth_headers, client, other_company_id): - """검증: 남의 회사 사업장의 템플릿은 바꿀 수 없다. +async def test_other_owners_place_is_blocked(auth_headers, client): + """검증: 남의 사업장의 템플릿은 바꿀 수 없다. 기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다).""" h = await auth_headers("tpl6") - intruder = await auth_headers("tpl7", other_company_id) + intruder = await auth_headers("tpl7") pid = await _place(client, h) blocked = await _set_template(client, intruder, pid, "stay-quiet-margin") diff --git a/solution/backend/tests/test_site_theme.py b/solution/backend/tests/test_site_theme.py index 3b6c811..a47a12d 100644 --- a/solution/backend/tests/test_site_theme.py +++ b/solution/backend/tests/test_site_theme.py @@ -129,11 +129,11 @@ async def test_published_site_theme_is_not_locked(auth_headers, client, db_engin assert changed["needs_rebuild"] is True -async def test_other_company_place_is_blocked(auth_headers, client, other_company_id): - """검증: 남의 회사 사업장의 디자인은 바꿀 수 없다. +async def test_other_owners_place_is_blocked(auth_headers, client): + """검증: 남의 사업장의 디자인은 바꿀 수 없다. 기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다).""" h = await auth_headers("thm7") - intruder = await auth_headers("thm8", other_company_id) + intruder = await auth_headers("thm8") pid = await _place(client, h) assert (await _set_theme(client, intruder, pid, _THEME))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value diff --git a/solution/backend/tests/test_snapshot.py b/solution/backend/tests/test_snapshot.py index e8d1d41..38ca3e5 100644 --- a/solution/backend/tests/test_snapshot.py +++ b/solution/backend/tests/test_snapshot.py @@ -10,13 +10,13 @@ from common.enums import FactStatus, MediaStatus, PlaceCategory, SourceType from services.snapshot import build_snapshot -async def _seed(db_engine, company_id, category=PlaceCategory.LODGING): +async def _seed(db_engine, owner_id, category=PlaceCategory.LODGING): pid = uuid.uuid4() async with db_engine.begin() as c: await c.execute( - text("INSERT INTO places (place_id, company_id, name, category, status, road_address, phone, verified_at) " + text("INSERT INTO places (place_id, owner_user_id, name, category, status, road_address, phone, verified_at) " "VALUES (:p,:c,:n,:cat,3,:addr,:tel,now())"), - {"p": pid, "c": uuid.UUID(company_id), "n": "스냅샷펜션", "cat": category.value, + {"p": pid, "c": uuid.UUID(owner_id), "n": "스냅샷펜션", "cat": category.value, "addr": "강원특별자치도 양양군 현북면 하조대3길 11", "tel": "033-000-0000"}, ) return pid @@ -53,10 +53,10 @@ class _Place: self.longitude = None -async def test_only_publishable_facts_enter_snapshot(db_engine, company_id): +async def test_only_publishable_facts_enter_snapshot(db_engine, owner_id): """검증: 여러 상태의 fact 를 섞어 넣는다. 기대결과: ★ VERIFIED·CORRECTED 만 스냅샷에 담긴다 — 미검증 값이 사이트로 새지 않는다.""" - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED) await _fact(db_engine, pid, "wifi", "true", FactStatus.CORRECTED) await _fact(db_engine, pid, "parking", "true", FactStatus.UNVERIFIED) @@ -69,10 +69,10 @@ async def test_only_publishable_facts_enter_snapshot(db_engine, company_id): assert keys == {"check_in_time", "wifi"} -async def test_only_approved_media_enters_snapshot(db_engine, company_id): +async def test_only_approved_media_enters_snapshot(db_engine, owner_id): """검증: 승인/확인대기/반려 사진을 섞어 넣는다. 기대결과: ★ APPROVED 만 담긴다 — Vision 신뢰도가 낮아 확인 큐에 남은 사진은 안 나간다.""" - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) await _media(db_engine, pid, "https://cdn.test/ok.jpg", MediaStatus.APPROVED) await _media(db_engine, pid, "https://cdn.test/pending.jpg", MediaStatus.PENDING_REVIEW) await _media(db_engine, pid, "https://cdn.test/no.jpg", MediaStatus.REJECTED) @@ -81,10 +81,10 @@ async def test_only_approved_media_enters_snapshot(db_engine, company_id): assert [m["url"] for m in snap["media"]] == ["https://cdn.test/ok.jpg"] -async def test_media_without_alt_is_excluded(db_engine, company_id): +async def test_media_without_alt_is_excluded(db_engine, owner_id): """검증: 승인됐지만 alt 텍스트가 없는 사진. 기대결과: 빠진다 — alt 없는 이미지는 접근성도 AI 검색 신호도 없다.""" - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) await _media(db_engine, pid, "https://cdn.test/noalt.jpg", MediaStatus.APPROVED, alt="") await _media(db_engine, pid, "https://cdn.test/withalt.jpg", MediaStatus.APPROVED, alt="침실 사진") @@ -92,10 +92,10 @@ async def test_media_without_alt_is_excluded(db_engine, company_id): assert [m["url"] for m in snap["media"]] == ["https://cdn.test/withalt.jpg"] -async def test_fact_labels_come_from_category_schema(db_engine, company_id): +async def test_fact_labels_come_from_category_schema(db_engine, owner_id): """검증: 스냅샷의 fact 라벨. 기대결과: 업종 스키마의 한글 라벨이 붙는다 — 화면이 key 를 그대로 노출하지 않게.""" - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED) snap = await build_snapshot(_Place(pid)) @@ -104,10 +104,10 @@ async def test_fact_labels_come_from_category_schema(db_engine, company_id): assert f["scope"] == "place" -async def test_unit_scoped_facts_carry_unit_id(db_engine, company_id): +async def test_unit_scoped_facts_carry_unit_id(db_engine, owner_id): """검증: 객실 단위 fact. 기대결과: unit_id 가 실려 빌더가 객실별로 묶을 수 있다.""" - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) uid = uuid.uuid4() async with db_engine.begin() as c: await c.execute(text("INSERT INTO units (unit_id, place_id, name, sort_order) VALUES (:u,:p,:n,0)"), @@ -120,10 +120,10 @@ async def test_unit_scoped_facts_carry_unit_id(db_engine, company_id): assert snap["facts"][0]["scope"] == "unit" -async def test_empty_place_gives_empty_snapshot(db_engine, company_id): +async def test_empty_place_gives_empty_snapshot(db_engine, owner_id): """검증: 아무것도 없는 사업장. 기대결과: 빈 스냅샷 — 게이트가 고유 콘텐츠 0건으로 거부할 재료가 된다.""" - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) snap = await build_snapshot(_Place(pid)) assert snap["facts"] == [] and snap["media"] == [] and snap["faqs"] == [] assert snap["place"]["name"] == "스냅샷펜션" @@ -156,13 +156,13 @@ class _RegionPlace(_Place): self.region_code = region_code -async def test_only_published_local_content_enters_snapshot(db_engine, company_id): +async def test_only_published_local_content_enters_snapshot(db_engine, owner_id): """검증: 검수대기·종료·발행 지역 정보를 섞어 넣는다. 기대결과: ★ PUBLISHED 만 담긴다 — 운영자가 검수하지 않은 외부 API 원문이 사이트로 새면 '미검증 값 노출 금지'가 깨진다(fact 를 VERIFIED 로 거르는 것과 같은 규칙).""" from common.enums import LocalContentStatus, LocalContentType - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "발행축제") await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.REVIEW, "검수대기축제") await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.ENDED, "종료축제") @@ -171,7 +171,7 @@ async def test_only_published_local_content_enters_snapshot(db_engine, company_i assert [c["title"] for c in snap["local"]["contents"]] == ["발행축제"] -async def test_local_content_outside_display_window_is_excluded(db_engine, company_id): +async def test_local_content_outside_display_window_is_excluded(db_engine, owner_id): """검증: 발행됐지만 노출 기간을 벗어난 지역 정보. 기대결과: 빠진다 — 끝난 축제를 '이번 주말 행사'로 걸어두는 것도 틀린 정보다.""" from datetime import datetime, timedelta, timezone @@ -179,7 +179,7 @@ async def test_local_content_outside_display_window_is_excluded(db_engine, compa from common.enums import LocalContentStatus, LocalContentType now = datetime.now(timezone.utc) - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "지금축제") await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "끝난축제", display_end_at=now - timedelta(days=1)) @@ -190,12 +190,12 @@ async def test_local_content_outside_display_window_is_excluded(db_engine, compa assert [c["title"] for c in snap["local"]["contents"]] == ["지금축제"] -async def test_local_content_is_scoped_to_the_places_region(db_engine, company_id): +async def test_local_content_is_scoped_to_the_places_region(db_engine, owner_id): """검증: 지역 캐시는 region_code 로 묶인다. 기대결과: 다른 지역의 발행 콘텐츠는 담기지 않는다.""" from common.enums import LocalContentStatus, LocalContentType - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "우리지역축제") await _local(db_engine, "5011025", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "남의지역축제") @@ -203,7 +203,7 @@ async def test_local_content_is_scoped_to_the_places_region(db_engine, company_i assert [c["title"] for c in snap["local"]["contents"]] == ["우리지역축제"] -async def test_region_code_is_derived_from_the_address_when_missing(db_engine, company_id): +async def test_region_code_is_derived_from_the_address_when_missing(db_engine, owner_id): """검증: region_code 가 비어 있지만 도로명주소는 있는 사업장. 기대결과: 주소에서 지역 키를 유도해 그 지역 콘텐츠를 담는다 — places.region_code 를 채우는 코드가 생기기 전에 만들어진 사업장(실측 28곳 중 25곳)이 영영 지역 정보 없이 발행되지 않게 한다.""" @@ -211,7 +211,7 @@ async def test_region_code_is_derived_from_the_address_when_missing(db_engine, c from services.external.naver import region_key derived = region_key(_Place("x").road_address) - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) await _local(db_engine, derived, LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "양양축제") snap = await build_snapshot(_RegionPlace(pid, "")) @@ -219,12 +219,12 @@ async def test_region_code_is_derived_from_the_address_when_missing(db_engine, c assert [c["title"] for c in snap["local"]["contents"]] == ["양양축제"] -async def test_place_without_any_region_key_gets_no_local_content(db_engine, company_id): +async def test_place_without_any_region_key_gets_no_local_content(db_engine, owner_id): """검증: 지역 코드도 읽을 만한 주소도 없는 사업장. 기대결과: 빈 목록 — 조회할 캐시 키가 없다. 지어내지 않는다.""" from common.enums import LocalContentStatus, LocalContentType - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "어딘가축제") place = _RegionPlace(pid, "") diff --git a/solution/backend/tests/test_verify_candidates.py b/solution/backend/tests/test_verify_candidates.py index 5a3e45e..275236b 100644 --- a/solution/backend/tests/test_verify_candidates.py +++ b/solution/backend/tests/test_verify_candidates.py @@ -136,13 +136,13 @@ async def test_candidates_require_configured_source(auth_headers, client, monkey assert body["result"]["code"] == ErrorType.LOCAL_NOT_CONFIGURED.value -async def test_candidates_scoped_to_company(auth_headers, client, other_company_id, monkeypatch): - """검증: 다른 회사 계정으로 남의 사업장 후보를 조회한다. +async def test_candidates_scoped_to_owner(auth_headers, client, monkeypatch): + """검증: 다른 사장님 계정으로 남의 사업장 후보를 조회한다. 기대결과: PLACE_NOT_FOUND.""" _patch_naver(monkeypatch, _match(kakao_client.MatchOutcome.MATCHED, _np("a", "b"), [], "x")) h1 = await auth_headers("o1") pid = await _place(client, h1) - h2 = await auth_headers("o2", other_company_id) + h2 = await auth_headers("o2") body = (await client.get(f"/v1/place/{pid}/verify/candidates", headers=h2)).json() assert body["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value diff --git a/solution/frontend/src/api/generated/model/companyData.ts b/solution/frontend/src/api/generated/model/companyData.ts deleted file mode 100644 index 1229a4c..0000000 --- a/solution/frontend/src/api/generated/model/companyData.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Web4Ai API - * OpenAPI spec version: 0.1.0 - */ - -export interface CompanyData { - company_id?: string; - name?: string; -} diff --git a/solution/frontend/src/api/generated/model/placeSearchItem.ts b/solution/frontend/src/api/generated/model/placeSearchItem.ts index 92eac8d..ee94833 100644 --- a/solution/frontend/src/api/generated/model/placeSearchItem.ts +++ b/solution/frontend/src/api/generated/model/placeSearchItem.ts @@ -11,7 +11,7 @@ import type { PlaceSearchItemCategory } from './placeSearchItemCategory'; /** * 공개 검색 결과 1건. -★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·company_id·소유자)은 +★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·소유자)은 하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다. ★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고, 확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다. diff --git a/solution/frontend/src/api/generated/model/reqSignup.ts b/solution/frontend/src/api/generated/model/reqSignup.ts index 61d3a32..ca5de09 100644 --- a/solution/frontend/src/api/generated/model/reqSignup.ts +++ b/solution/frontend/src/api/generated/model/reqSignup.ts @@ -5,10 +5,9 @@ * OpenAPI spec version: 0.1.0 */ import type { ReqSignupName } from './reqSignupName'; -import type { ReqSignupCompanyName } from './reqSignupCompanyName'; /** - * id/pw 가입. 가입 = 새 테넌트(회사) 1개 + 그 회사의 첫 계정 1개다. + * id/pw 가입. 가입 = 계정 1개다. ★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면 한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라 @@ -19,5 +18,4 @@ export interface ReqSignup { password?: string; name?: ReqSignupName; email?: string; - company_name?: ReqSignupCompanyName; } diff --git a/solution/frontend/src/api/generated/model/reqSignupCompanyName.ts b/solution/frontend/src/api/generated/model/reqSignupCompanyName.ts deleted file mode 100644 index 5b6614a..0000000 --- a/solution/frontend/src/api/generated/model/reqSignupCompanyName.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Web4Ai API - * OpenAPI spec version: 0.1.0 - */ - -export type ReqSignupCompanyName = string | null; diff --git a/solution/frontend/src/api/generated/model/resMe.ts b/solution/frontend/src/api/generated/model/resMe.ts index 84fa745..2441fe4 100644 --- a/solution/frontend/src/api/generated/model/resMe.ts +++ b/solution/frontend/src/api/generated/model/resMe.ts @@ -11,7 +11,6 @@ import type { ResMeEmail } from './resMeEmail'; import type { ResMeContactNumber } from './resMeContactNumber'; import type { UserRole } from './userRole'; import type { AuthProvider } from './authProvider'; -import type { ResMeCompany } from './resMeCompany'; export interface ResMe { result?: ErrorInfo; @@ -23,5 +22,4 @@ export interface ResMe { contact_number?: ResMeContactNumber; role?: UserRole; provider?: AuthProvider; - company?: ResMeCompany; } diff --git a/solution/frontend/src/api/generated/model/resMeCompany.ts b/solution/frontend/src/api/generated/model/resMeCompany.ts deleted file mode 100644 index 4a08cff..0000000 --- a/solution/frontend/src/api/generated/model/resMeCompany.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Web4Ai API - * OpenAPI spec version: 0.1.0 - */ -import type { CompanyData } from './companyData'; - -export type ResMeCompany = CompanyData | null; diff --git a/solution/frontend/src/api/generated/model/showcaseItem.ts b/solution/frontend/src/api/generated/model/showcaseItem.ts index 5d3d0a7..b6374d3 100644 --- a/solution/frontend/src/api/generated/model/showcaseItem.ts +++ b/solution/frontend/src/api/generated/model/showcaseItem.ts @@ -12,7 +12,7 @@ import type { ShowcaseItemThumbnailUrl } from './showcaseItemThumbnailUrl'; * 랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.** ★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다. - place_id·company_id·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과 + place_id·소유자·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과 발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다. */ export interface ShowcaseItem { diff --git a/solution/frontend/src/components/layout/AppShell.tsx b/solution/frontend/src/components/layout/AppShell.tsx index 8fa7763..da25315 100644 --- a/solution/frontend/src/components/layout/AppShell.tsx +++ b/solution/frontend/src/components/layout/AppShell.tsx @@ -82,7 +82,6 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav? className="mb-2 block truncate rounded-md px-2 py-1 text-[11px] text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60" > {userLabel(user)} - {user.companyName ? ` · ${user.companyName}` : ''} </Link> ) : ( <div className="mb-2 truncate px-2 py-1 text-[11px] text-sidebar-foreground"> diff --git a/solution/frontend/src/pages/AccountPage.tsx b/solution/frontend/src/pages/AccountPage.tsx index 6c2b9fb..0d07484 100644 --- a/solution/frontend/src/pages/AccountPage.tsx +++ b/solution/frontend/src/pages/AccountPage.tsx @@ -10,8 +10,8 @@ import {toAuthUser, useAuthStore} from '@/stores/auth'; /** * 내 정보 — `PATCH /v1/auth/me` 한 곳이 받는 것만 그린다. * - * ★ 상호(company)는 읽기 전용이다. Req_UpdateMe 에 없다 — 자기 소속을 스스로 바꾸지 못하게 - * 일부러 뺀 필드라, 입력칸을 두면 저장을 눌러도 아무 일이 안 일어난다. + * ★ 상호 칸은 없다. 회사(테넌트)를 걷어내면서(2026-09-08) 계정에 상호가 없어졌다 — + * 가게 이름은 사업장(place)이 갖는다. * ★ 구글 계정에는 바꿀 비밀번호가 없다(서버가 ACCOUNT_PROVIDER_CONFLICT 로 막는다) — * 입력칸 자체를 그리지 않는다. */ @@ -77,13 +77,6 @@ export function AccountPage() { {isGoogle ? '구글 계정으로 로그인합니다.' : '아이디는 바꿀 수 없습니다.'} </p> </Field> - - <Field label="상호"> - <p className="text-sm">{data?.company?.name ?? '-'}</p> - <p className="mt-0.5 text-xs text-muted-foreground"> - 상호 변경은 고객센터로 문의해 주세요. - </p> - </Field> </section> <section className="space-y-4 rounded-xl border border-border bg-card p-5"> diff --git a/solution/frontend/src/pages/BuilderPage.tsx b/solution/frontend/src/pages/BuilderPage.tsx index 9f596bc..653e16f 100644 --- a/solution/frontend/src/pages/BuilderPage.tsx +++ b/solution/frontend/src/pages/BuilderPage.tsx @@ -212,10 +212,9 @@ export function BuilderPage() { <span className="h-3.5 w-px bg-white/20" /> <span className="max-w-[14rem] truncate text-background/70" - title={user.companyName ? `${userLabel(user)} · ${user.companyName}` : userLabel(user)} + title={userLabel(user)} > {userLabel(user)} - {user.companyName ? ` · ${user.companyName}` : ''} </span> <button type="button" diff --git a/solution/frontend/src/pages/SignupPage.tsx b/solution/frontend/src/pages/SignupPage.tsx index 6048fa1..3c355f6 100644 --- a/solution/frontend/src/pages/SignupPage.tsx +++ b/solution/frontend/src/pages/SignupPage.tsx @@ -31,7 +31,6 @@ export function SignupPage() { passwordConfirm: '', name: '', email: '', - companyName: '', }); const [isSubmitting, setIsSubmitting] = useState(false); @@ -63,7 +62,6 @@ export function SignupPage() { password: form.password, name: form.name.trim() || null, email: form.email.trim(), - company_name: form.companyName.trim() || null, }); if (res.result?.success === false) { notifyApiError({data: res}, '가입하지 못했습니다.'); @@ -180,17 +178,6 @@ export function SignupPage() { required /> </div> - <div> - <label htmlFor="signup-company" className="mb-1.5 block text-xs font-semibold"> - 상호 <span className="font-normal text-muted-foreground">(선택)</span> - </label> - <Input - id="signup-company" - value={form.companyName} - onChange={set('companyName')} - placeholder="비우면 이름으로 채웁니다" - /> - </div> </div> <Button type="submit" variant="primary" className="w-full" isLoading={isSubmitting}> diff --git a/solution/frontend/src/stores/auth.ts b/solution/frontend/src/stores/auth.ts index 82fad43..99fa40f 100644 --- a/solution/frontend/src/stores/auth.ts +++ b/solution/frontend/src/stores/auth.ts @@ -8,8 +8,6 @@ export interface AuthUser { name?: string; email?: string; role: number; - companyId?: string; - companyName?: string; } /** @@ -26,8 +24,6 @@ export function toAuthUser(res: ResMe): AuthUser { name: res.name ?? undefined, email: res.email ?? undefined, role: res.role ?? UserRole.USER, - companyId: res.company?.company_id, - companyName: res.company?.name, }; } From 9f16c3224b5d5a0173d1d19e520b3889dffe9a32 Mon Sep 17 00:00:00 2001 From: Mina Choi <mnchoi@o2o.kr> Date: Tue, 8 Sep 2026 13:01:47 +0900 Subject: [PATCH 17/18] =?UTF-8?q?[feat]=20solution/backend,frontend:=20?= =?UTF-8?q?=EB=82=B4=20=EC=82=AC=EC=9D=B4=ED=8A=B8=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9D=84=20=EC=B9=B4=EB=93=9C=EB=A1=9C=20=E2=80=94=20=EC=8D=B8?= =?UTF-8?q?=EB=84=A4=EC=9D=BC=C2=B7=EC=A3=BC=EC=86=8C=C2=B7=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=20=C2=B7=20=EB=B0=9C=ED=96=89=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=BC=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목록 줄이 아이콘·상호·배지·주소 넷뿐이었다. 서버는 이미 road_address·created_at· published_at 을 주는데 화면이 안 썼다. 실측(계정 test): 35줄 중 34줄이 발행 전이고 같은 상호 '버터브루' 가 4줄이라 어느 게 어느 건지 가릴 단서가 화면에 없었다. 리서치 — Wix My Sites 는 이름·URL·Premium·협업자만 두고 검색·그리드/리스트 전환·폴더가 있다. Sites API 문서가 권하는 조합은 displayName·thumbnail·viewUrl·editUrl 이다. 아임웹 내사이트는 **실제 화면을 열어 봤다**(imweb.me 가이드): 줄 왼쪽에 큰 가로형 썸네일, 상호 아래 도메인, 그리고 도메인/SSL·PG 신청처럼 **안 끝난 설정**을 줄 안에 배지로 늘어놓는다. 공통 원칙은 목록이 ① 구분 ② 상태 ③ 여는 길 셋만 한다는 것 — 통계는 사이트 안 대시보드다. - protocol·site_service: `MySiteData.thumbnail_url` 추가. 목록이 사이트 행을 이미 조인해 읽고 있어서 쿼리는 그대로다 - site_thumbnail: 공개 주소에 `?v=<발행 버전>`. 블롭 이름은 고정이고 내용만 덮어쓰므로 주소가 안 변하면 사진을 바꿔 재발행해도 **캐시에 남은 지난 그림**이 계속 보인다 (CACHE_CONTROL 60초로는 그 60초를 못 막는다). 이름에 버전을 넣지 않은 이유는 사이트당 블롭이 발행 횟수만큼 쌓이는데 지우는 코드가 없어서다 - site_thumbnail: 썸네일 전용 저장소 스위치(`THUMBNAIL_BLOB_*`). 예전엔 키 하나가 사이트 전체 업로드(azure_static)까지 같이 켰다 — 둘은 필요한 저장소가 다르다 (사이트는 정적 호스팅 `$web`, 썸네일은 이미지 버킷이면 된다) - SitesPage: 줄 → **카드 그리드**. 썸네일은 16:10(브라우저 창 비율 — 사이트 미리보기를 1:1 로 자르면 무슨 사이트인지 못 알아본다). 검색(상호·주소, 공백 무시) + 상태 칸 `전체/발행됨/발행 전` 에 건수. 판정은 `bucketOf` 하나가 소유한다(배지·필터·정렬이 갈라지면 건수가 어긋나 목록을 못 믿게 된다). 검색 0건 화면을 처음 온 사람의 빈 화면과 분리했다 — 35개 있는데 "아직 없습니다" 라고 말하던 자리다 - 카드 골격은 **상태와 무관하게 같다**. 발행 전 카드에만 줄이 하나 더 붙어 높이와 버튼 위치가 어긋났다(사장님 지적). 버튼 문구도 '편집' 하나로 — 하는 일이 같은데 글자만 달랐다 아직 그림이 없는 사이트가 대부분이다. 썸네일은 발행에 성공해야 생긴다. 검증: 백엔드 전체 통과. 목록 줄이 주소·생성일·썸네일을 들고 오는지, 발행 전 줄에 `thumbnail_url` 키가 아예 없는지, 재발행하면 `?v=1` → `?v=2` 로 주소가 바뀌는지 4건 추가. 프론트 tsc+eslint 통과. 실제 발행으로 블롭 업로드(232KB) → 공개 주소 200 → 목록 반영 확인. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QLWEFx4X3XRmKewUKjJWow --- docs/DEVLOG.md | 43 +++ solution/backend/router/v1/site/protocol.py | 4 +- solution/backend/services/build_service.py | 10 +- solution/backend/services/site_service.py | 11 +- solution/backend/services/site_thumbnail.py | 88 ++++- solution/backend/tests/test_my_sites.py | 36 ++ solution/backend/tests/test_site_thumbnail.py | 21 +- .../frontend/src/api/generated/model/index.ts | 4 +- .../src/api/generated/model/mySiteData.ts | 3 + .../generated/model/mySiteDataThumbnailUrl.ts | 8 + solution/frontend/src/pages/SitesPage.tsx | 347 +++++++++++++++--- 11 files changed, 488 insertions(+), 87 deletions(-) create mode 100644 solution/frontend/src/api/generated/model/mySiteDataThumbnailUrl.ts diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index bbd33ea..bf68dea 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,49 @@ --- +## 2026-09-08 — 내 사이트 목록에 썸네일·주소·시각 — 발행할 때마다 그림이 바뀐다 + +**무슨 일** +목록 줄이 아이콘·상호·배지·주소 넷뿐이었다. 서버는 이미 `road_address`·`created_at`· +`published_at` 을 주고 있는데 화면이 안 썼다. 한 계정에 '버터브루' 가 4줄 있으면 어느 게 +어느 건지 가릴 단서가 화면에 하나도 없다. + +**리서치** (Wix · 아임웹) +- Wix `My Sites` 줄에 보이는 건 이름·URL·Premium·협업자뿐이고 **썸네일도 수정일도 없다.** + 대신 Sites API 문서가 "이렇게 그려라" 로 지목한 조합은 `displayName · thumbnail · viewUrl · + editUrl` 이고, 정렬은 최근 수정순이다 — 화면보다 API 권고 쪽이 우리 상황에 맞다. +- 아임웹 내사이트는 기본 정보 + 액션(관리자 접속·복제·템플릿 변경·소유권 이전), + 리셀러 목록은 **만료일**을 목록에서 바로 본다. 방문자·주문 숫자는 목록이 아니라 + 사이트 안 대시보드에 있다. +- 공통: 목록은 **구분 · 상태 · 여는 길** 셋만 한다. 그리고 **둘 다 생성일을 안 쓴다** — + 구분은 그림·주소·이름이 하고, 시각은 "마지막으로 뭔가 한 시각" 이 쓰인다. + +**바꾼 것** +- `MySiteData.thumbnail_url` 추가(`site_service._my_site_row`). 목록이 사이트 행을 이미 + 조인해 읽고 있어서 쿼리는 그대로다 +- 줄 앞에 썸네일. 없으면 업종 아이콘으로 떨어지고, 로드 실패해도 아이콘으로 되돌린다 — + 블롭이 지워진 옛 주소에서 깨진 그림이 뜨는 것보다 낫다 +- 줄 아래 한 칸: `도로명 주소 · 시각`. 시각은 **발행됐으면 발행일, 아니면 만든 날** 하나만 + 쓴다(위 리서치의 결론). 올해면 연도를 뗀다 — 줄이 좁아 주소가 먼저 잘린다 + +**썸네일이 발행마다 바뀌게** (`site_thumbnail.public_url`) +블롭 이름은 `thumbs/<slug>.<ext>` 로 고정이고 내용만 `overwrite=True` 로 덮어쓴다. 그래서 +주소가 안 변했고, 사장님이 사진을 바꿔 재발행해도 **캐시에 남은 지난 그림**이 계속 보였다 +(`CACHE_CONTROL` 60초만으로는 그 60초를 못 막는다). 주소에 `?v=<발행 버전>` 을 붙인다. +→ 이름에 버전을 넣지 않는 이유: 사이트당 블롭이 발행 횟수만큼 쌓이는데 지우는 코드가 없다. +→ `scripts/backfill_thumbnails.py` 처럼 그 시점 버전이 없는 경로는 `version=None` 으로 + 그냥 붙이지 않는다. + +**아직 그림이 한 장도 없다** — 로컬·현재 DB 의 사이트 39개 전부 `thumbnail_url` 이 NULL 이다. +버그가 아니라 `AZURE_STORAGE_CONNECTION_STRING` 이 비어 `site_thumbnail.is_configured()` 가 +False 라서다(썸네일은 Blob 에만 올라간다). 키를 채우면 다음 발행부터 채워진다. + +**검증** — 백엔드 전체 통과. 목록 줄이 주소·생성일·썸네일을 들고 오는지, 발행 안 한 줄에 +`thumbnail_url` 키가 아예 없는지, **재발행하면 `?v=1` → `?v=2` 로 주소가 바뀌는지** 4건 추가. +프론트 `tsc + eslint` 통과. + +--- + ## 2026-09-08 — 회사(테넌트)를 걷어냈다 — 사장님 계정이 곧 스코프다 **무슨 일** diff --git a/solution/backend/router/v1/site/protocol.py b/solution/backend/router/v1/site/protocol.py index 573df50..816980c 100644 --- a/solution/backend/router/v1/site/protocol.py +++ b/solution/backend/router/v1/site/protocol.py @@ -85,6 +85,8 @@ class MySiteData(WebPacketProtocol): domain: Optional[str] = None template_id: Optional[str] = None published_at: Optional[datetime] = None + # 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url). + thumbnail_url: Optional[str] = None # 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다. needs_rebuild: bool = False @@ -235,7 +237,7 @@ class ShowcaseItem(WebPacketProtocol): """랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.** ★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다. - place_id·company_id·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과 + place_id·소유자·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과 발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다.""" name: str diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py index 5869167..e26a95c 100644 --- a/solution/backend/services/build_service.py +++ b/solution/backend/services/build_service.py @@ -97,18 +97,18 @@ async def _log(site_id, version_id, action: PublishAction, result: PublishResult async def run_build(job: dict) -> dict: - """BUILD 잡 핸들러. payload: {place_id, company_id, publish?, requested_by?} + """BUILD 잡 핸들러. payload: {place_id, owner_user_id, publish?, requested_by?} publish=True 면 게이트를 통과했을 때 바로 발행까지 한다.""" payload = job["payload"] place_id = payload["place_id"] - company_id = payload["company_id"] + owner_user_id = payload["owner_user_id"] want_publish = bool(payload.get("publish")) err, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)), + lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)), ) if err != ErrorType.SUCCESS or place is None: raise BuildAborted(f"사업장을 찾을 수 없다: {place_id}") @@ -246,7 +246,7 @@ async def run_build(job: dict) -> dict: result["azure"] = azure_result # ★ 페이지가 실제로 올라간 뒤에 썸네일을 남긴다 — 없는 페이지의 그림을 쇼케이스에 걸지 않는다. # 실패해도 발행은 성공이다(스크린샷이 아니라 대표 사진이라, 없으면 글자 카드로 떨어진다). - thumbnail_url = await site_thumbnail.store(slug, snapshot) + thumbnail_url = await site_thumbnail.store(slug, snapshot, version_no) if thumbnail_url: result["thumbnail_url"] = thumbnail_url # ★ 정적 파일이 올라간 **뒤에** 통보한다. 먼저 알리면 크롤러가 옛 파일을 가져간다. @@ -293,7 +293,7 @@ async def run_build(job: dict) -> dict: await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), lambda s: _place_crud.update_place( - s, uuid.UUID(company_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value} + s, uuid.UUID(owner_user_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value} ), ) await _log(site.site_id, version.site_version_id, PublishAction.PUBLISH, PublishResult.SUCCESS, None, diff --git a/solution/backend/services/site_service.py b/solution/backend/services/site_service.py index 3103797..a5fc221 100644 --- a/solution/backend/services/site_service.py +++ b/solution/backend/services/site_service.py @@ -104,7 +104,7 @@ class SiteService: err_type, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)), + lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)), ) if err_type != ErrorType.SUCCESS: return ErrorType.PLACE_NOT_FOUND, None @@ -420,16 +420,16 @@ class SiteService: return cleaned async def list_my_sites(self, user_info: UserInfo, pg: PageParams) -> Res_MySites: - """로그인한 계정(회사)이 가진 사이트 전부. + """로그인한 사장님이 가진 사이트 전부. 사업장 목록(/v1/place/list)과 따로 두는 이유: 화면이 알아야 하는 건 '사업장이 있다'가 아니라 '발행돼 있나 · 주소가 뭔가 · 다시 구워야 하나'다.""" res = Res_MySites(page=pg.page, size=pg.size) - cid = uuid.UUID(user_info.company_id) + uid = uuid.UUID(user_info.user_id) err_type, rows, total = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: self.crud.list_company_sites(s, cid, pg.skip, pg.size), + lambda s: self.crud.list_owner_sites(s, uid, pg.skip, pg.size), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) @@ -454,6 +454,7 @@ class SiteService: domain=getattr(site, "domain", None), template_id=getattr(site, "template_id", None), published_at=getattr(site, "published_at", None), + thumbnail_url=getattr(site, "thumbnail_url", None), needs_rebuild=bool(site is not None and changed and (built_at is None or changed > built_at)), ) @@ -526,7 +527,7 @@ class SiteService: job_id, created = await enqueue_job( self.queue, JobType.BUILD, { - "place_id": place_id, "company_id": user_info.company_id, + "place_id": place_id, "owner_user_id": user_info.user_id, "publish": req.publish, "requested_by": user_info.user_id, }, dedupe_key=f"build:{place_id}", diff --git a/solution/backend/services/site_thumbnail.py b/solution/backend/services/site_thumbnail.py index 92b9e24..376b476 100644 --- a/solution/backend/services/site_thumbnail.py +++ b/solution/backend/services/site_thumbnail.py @@ -18,7 +18,7 @@ import asyncio import os import httpx -from azure.storage.blob import BlobServiceClient, ContentSettings +from azure.storage.blob import BlobClient, BlobServiceClient, ContentSettings from common.logger import LOG from services import azure_static, site_payload @@ -46,19 +46,71 @@ MAX_BYTES = 5 * 1024 * 1024 CACHE_CONTROL = "public, max-age=60, must-revalidate" +# ── 썸네일 전용 저장소 ──────────────────────────────────────────────────────── +# ★ 왜 스위치를 따로 두나 +# 원래는 `AZURE_STORAGE_CONNECTION_STRING` 하나가 사이트 업로드(azure_static)와 썸네일을 +# **같이** 켰다. 그런데 그 둘은 필요한 저장소가 다르다 — 사이트는 정적 호스팅(`$web`)이고 +# 썸네일은 그냥 이미지 버킷이면 된다. 하나로 묶어 두면 "썸네일 좀 보자" 고 키를 꽂는 순간 +# **발행할 때마다 사이트 전체가 그 버킷에 업로드된다.** 지금 우리가 빌려 쓰는 곳은 +# negodata·infinith 와 공용인 미디어 컨테이너라 그렇게 되면 안 된다. +# +# ★ 값 출처: o2o-negosium/negodata/backend/config/config.local.toml `[StorageConfig]`. +# 같은 계정/컨테이너를 root 디렉터리로만 가른다(negodata/ · infinith/ · web4ai/) — +# 그쪽 관례를 그대로 따른 것이지 우리 계정이 아니다. +# +# ⚠️ **임시다.** 이 컨테이너는 정적 사이트 호스팅이 아니라 발행본을 못 올린다. 그리고 SAS 가 +# 컨테이너 전체에 racwdl(삭제 포함)이라, 남의 프로젝트 파일에 닿을 수 있는 자리다 — +# web4ai 전용 스토리지 계정이 생기면 이 블록을 걷고 azure_static 쪽으로 되돌린다. +_BASE_ENV = "THUMBNAIL_BLOB_BASE_URL" # https://<계정>.blob.core.windows.net/<컨테이너> +_SAS_ENV = "THUMBNAIL_BLOB_SAS_TOKEN" # `?sv=...` (앞의 물음표는 있어도 없어도 된다) +_ROOT_ENV = "THUMBNAIL_BLOB_ROOT" # 컨테이너 안에서 우리가 쓰는 디렉터리. 예: web4ai + + +def _blob_base() -> str: + return os.environ.get(_BASE_ENV, "").strip().rstrip("/") + + +def _blob_sas() -> str: + return os.environ.get(_SAS_ENV, "").strip().lstrip("?") + + +def _blob_root() -> str: + return os.environ.get(_ROOT_ENV, "").strip().strip("/") + + +def uses_blob_store() -> bool: + """썸네일 전용 저장소를 쓰는가. 아니면 예전대로 azure_static 설정을 따른다.""" + return bool(_blob_base() and _blob_sas()) + + def is_configured() -> bool: - return azure_static.is_configured() + return uses_blob_store() or azure_static.is_configured() def blob_name(slug: str, ext: str) -> str: + if uses_blob_store(): + # base_url 에 컨테이너까지 들어 있다 — 여기서는 컨테이너 안쪽 경로만 만든다. + return "/".join(part for part in (_blob_root(), THUMB_DIR, f"{slug}.{ext}") if part) prefix = os.environ.get("AZURE_STORAGE_PREFIX", azure_static.DEFAULT_PREFIX).strip().strip("/") return "/".join(part for part in (prefix, THUMB_DIR, f"{slug}.{ext}") if part) -def public_url(slug: str, ext: str) -> str: +def public_url(slug: str, ext: str, version: int | None = None) -> str: """공개 주소. 발행 사이트와 같은 오리진이다 — 접두사는 오리진 경로로 흡수된다 - (CLAUDE.md 'AZURE_STORAGE_PREFIX 와 루트 절대경로는 충돌한다').""" - return f"{site_payload.publish_origin()}/{THUMB_DIR}/{slug}.{ext}" + (CLAUDE.md 'AZURE_STORAGE_PREFIX 와 루트 절대경로는 충돌한다'). + + ★ `?v=<버전>` 은 캐시 무효화다. 블롭 이름은 발행마다 그대로고 내용만 덮어쓰므로 + (overwrite=True), 주소가 안 변하면 브라우저·CDN 이 **옛 그림을 계속 보여준다.** + 아래 CACHE_CONTROL(60초)만으로는 부족하다 — 그 60초 동안 사장님은 방금 바꾼 사진이 + 아니라 지난 발행의 사진을 본다. 버전을 붙이면 발행 즉시 새 주소가 된다. + ★ 이름에 버전을 넣지 않는 이유: 사이트당 블롭이 발행 횟수만큼 쌓이고, 지우는 코드가 없다. + ★ version 이 None 이면 붙이지 않는다 — 옛 발행분을 사후에 채우는 경로 + (scripts/backfill_thumbnails.py)에는 그 시점의 버전이 없다.""" + # ★ 저장소가 발행 오리진 밖이면 주소도 그쪽이다. 여기서 publish_origin 을 쓰면 + # 그림은 블롭에 올라가 있는데 카드는 우리 사이트 주소를 가리켜 전부 404 다. + base = f"{_blob_base()}/{blob_name(slug, ext)}" if uses_blob_store() \ + else f"{site_payload.publish_origin()}/{THUMB_DIR}/{slug}.{ext}" + return f"{base}?v={version}" if version is not None else base async def _fetch(url: str) -> tuple[bytes, str, str] | None: @@ -109,6 +161,18 @@ async def _fetch(url: str) -> tuple[bytes, str, str] | None: def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str: + name = blob_name(slug, ext) + settings = ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL) + + if uses_blob_store(): + # ★ SAS 는 연결 문자열이 아니다 — from_connection_string 이 못 받는다. + # 블롭 주소에 토큰을 붙여 그 한 파일에만 붙는다(컨테이너 클라이언트를 만들지 않는다: + # 남의 디렉터리를 훑을 수 있는 핸들을 굳이 들고 있지 않는다). + BlobClient.from_blob_url(f"{_blob_base()}/{name}?{_blob_sas()}").upload_blob( + data, overwrite=True, content_settings=settings + ) + return name + connection_string = os.environ["AZURE_STORAGE_CONNECTION_STRING"].strip() container_name = ( os.environ.get("AZURE_STORAGE_CONTAINER", azure_static.DEFAULT_CONTAINER).strip() @@ -116,18 +180,12 @@ def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str: ) service = BlobServiceClient.from_connection_string(connection_string) container = service.get_container_client(container_name) - name = blob_name(slug, ext) - container.upload_blob( - name=name, - data=data, - overwrite=True, - # cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다. - content_settings=ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL), - ) + # cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다. + container.upload_blob(name=name, data=data, overwrite=True, content_settings=settings) return name -async def store(slug: str, snapshot: dict) -> str | None: +async def store(slug: str, snapshot: dict, version: int | None = None) -> str | None: """대표 사진을 썸네일로 올리고 공개 URL 을 돌려준다. 못 하면 None(발행은 그대로 간다). SDK 의 동기 I/O 는 별도 스레드에서 돈다 — azure_static.publish 와 같은 이유로, @@ -153,4 +211,4 @@ async def store(slug: str, snapshot: dict) -> str | None: return None LOG.i(f"[thumbnail] {slug} → {name} ({len(data)} bytes · {content_type})") - return public_url(slug, ext) + return public_url(slug, ext, version) diff --git a/solution/backend/tests/test_my_sites.py b/solution/backend/tests/test_my_sites.py index c410545..3abae3d 100644 --- a/solution/backend/tests/test_my_sites.py +++ b/solution/backend/tests/test_my_sites.py @@ -53,6 +53,42 @@ async def test_site_row_is_joined_into_the_line(auth_headers, client): assert row["status"] == SiteStatus.DRAFT.value +async def test_row_carries_what_the_card_draws(auth_headers, client, db_engine): + """검증: 목록 줄이 카드가 그릴 값을 다 들고 온다 — 주소·생성일·썸네일. + + ★ 왜 이걸 본다: 같은 상호로 만든 사업장이 여러 줄일 때(실측: 한 계정에 '버터브루' 4줄) + 이름과 상태 배지만으로는 어느 게 어느 건지 가릴 수 없다. 가르는 값은 주소와 시각이고, + **한 번이라도 발행한 줄은 그림**이다.""" + h = await auth_headers("my2b") + pid = await _place(client, h, "카드펜션") + async with db_engine.begin() as conn: + await conn.execute( + text("UPDATE places SET road_address = :addr WHERE place_id = :pid"), + {"addr": "강원특별자치도 양양군 현북면 하조대3길 12", "pid": uuid.UUID(pid)}, + ) + await conn.execute( + text("INSERT INTO sites (site_id, place_id, domain, status, thumbnail_url) " + "VALUES (:s, :p, :d, :st, :t)"), + {"s": uuid.uuid4(), "p": uuid.UUID(pid), "d": "card-stay", + "st": SiteStatus.PUBLISHED.value, "t": "https://w4ai.o2o.kr/thumbs/card-stay.jpg?v=2"}, + ) + + row = (await _list(client, h))["sites"][0] + assert row["road_address"] == "강원특별자치도 양양군 현북면 하조대3길 12" + assert row["created_at"] + assert row["thumbnail_url"] == "https://w4ai.o2o.kr/thumbs/card-stay.jpg?v=2" + + +async def test_row_without_a_site_has_no_thumbnail(auth_headers, client): + """검증: 아직 발행 안 한 줄은 그림이 없다(키 자체가 없다). + 기대결과: 화면이 '그림 없음' 자리를 그릴 근거가 된다 — 빈 문자열로 오면 깨진 이미지가 뜬다.""" + h = await auth_headers("my2c") + await _place(client, h, "그림없는펜션") + + row = (await _list(client, h))["sites"][0] + assert "thumbnail_url" not in row + + async def test_other_owners_sites_are_not_listed(auth_headers, client): """검증: 사장님 스코프. 남의 사업장은 보이지 않는다. 기대결과: 각자 자기 것만 1건.""" diff --git a/solution/backend/tests/test_site_thumbnail.py b/solution/backend/tests/test_site_thumbnail.py index 44b2808..3d81522 100644 --- a/solution/backend/tests/test_site_thumbnail.py +++ b/solution/backend/tests/test_site_thumbnail.py @@ -110,9 +110,11 @@ async def test_사이트_디렉터리_밖의_thumbs_에_올린다(blob, monkeypa _transport(monkeypatch, lambda req: httpx.Response(200, headers={"content-type": "image/jpeg"}, content=b"jpegbytes")) snapshot = _snapshot({"media_id": "m1", "url": "https://cdn.test/front.jpg", "unit_id": None}) - url = await site_thumbnail.store("butter", snapshot) + url = await site_thumbnail.store("butter", snapshot, 3) - assert url == "https://w4ai.o2o.kr/thumbs/butter.jpg" + # ★ `?v=` 는 캐시 무효화다. 블롭 이름은 그대로 덮어쓰므로 주소가 안 변하면 + # 브라우저·CDN 이 지난 발행의 그림을 계속 보여준다(site_thumbnail.public_url). + assert url == "https://w4ai.o2o.kr/thumbs/butter.jpg?v=3" name = f"{azure_static.DEFAULT_PREFIX}/thumbs/butter.jpg" assert set(blob.uploads) == {name} assert not name.startswith(f"{azure_static.DEFAULT_PREFIX}/{azure_static.SITE_ROOT_DIR}/") @@ -131,8 +133,8 @@ async def test_발행하면_사이트에_썸네일_주소가_남는다(auth_head from services import build_service from tests.test_build_publish import _approved_media, _place, _run, _verified_facts - async def _store(slug, snapshot): - return f"https://w4ai.o2o.kr/thumbs/{slug}.jpg" + async def _store(slug, snapshot, version=None): + return f"https://w4ai.o2o.kr/thumbs/{slug}.jpg?v={version}" monkeypatch.setattr(build_service.site_thumbnail, "store", _store) @@ -146,6 +148,15 @@ async def test_발행하면_사이트에_썸네일_주소가_남는다(auth_head site = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()["site"] assert site["thumbnail_url"].startswith("https://w4ai.o2o.kr/thumbs/") + assert site["thumbnail_url"].endswith("?v=1") + + # ★ 재발행하면 주소가 바뀌어야 한다. 블롭 이름은 그대로 덮어쓰므로, 주소가 그대로면 + # 사장님은 사진을 바꾸고 다시 발행해도 캐시에 남은 **지난 그림**을 계속 본다. + await client.post(f"/v1/place/{pid}/site/build", headers=h, json={"publish": True}) + await _run() + again = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()["site"] + assert again["thumbnail_url"].endswith("?v=2") + assert again["thumbnail_url"] != site["thumbnail_url"] async def test_썸네일을_못_만들어도_발행은_성공한다(auth_headers, client, db_engine, monkeypatch): @@ -154,7 +165,7 @@ async def test_썸네일을_못_만들어도_발행은_성공한다(auth_headers from services import build_service from tests.test_build_publish import _approved_media, _place, _run, _verified_facts - async def _store(slug, snapshot): + async def _store(slug, snapshot, version=None): return None monkeypatch.setattr(build_service.site_thumbnail, "store", _store) diff --git a/solution/frontend/src/api/generated/model/index.ts b/solution/frontend/src/api/generated/model/index.ts index d616f6f..2e38a7b 100644 --- a/solution/frontend/src/api/generated/model/index.ts +++ b/solution/frontend/src/api/generated/model/index.ts @@ -10,7 +10,6 @@ export * from './auditCheckDataRecommendation'; export * from './authProvider'; export * from './buildStatus'; export * from './checkSlugParams'; -export * from './companyData'; export * from './errorInfo'; export * from './errorInfoCode'; export * from './errorInfoDesc'; @@ -83,6 +82,7 @@ export * from './mySiteData'; export * from './mySiteDataCreatedAt'; export * from './mySiteDataDomain'; export * from './mySiteDataPublishedAt'; +export * from './mySiteDataThumbnailUrl'; export * from './mySiteDataRoadAddress'; export * from './mySiteDataSiteId'; export * from './mySiteDataStatus'; @@ -139,7 +139,6 @@ export * from './reqGoogleLogin'; export * from './reqLogin'; export * from './reqPublishLocalContent'; export * from './reqSignup'; -export * from './reqSignupCompanyName'; export * from './reqSignupName'; export * from './reqSiteSlug'; export * from './reqSiteStatus'; @@ -222,7 +221,6 @@ export * from './resLocalContentListMsg'; export * from './resLogin'; export * from './resLoginMsg'; export * from './resMe'; -export * from './resMeCompany'; export * from './resMeContactNumber'; export * from './resMeEmail'; export * from './resMeMsg'; diff --git a/solution/frontend/src/api/generated/model/mySiteData.ts b/solution/frontend/src/api/generated/model/mySiteData.ts index cb463b1..b3ef1d2 100644 --- a/solution/frontend/src/api/generated/model/mySiteData.ts +++ b/solution/frontend/src/api/generated/model/mySiteData.ts @@ -13,6 +13,7 @@ import type { MySiteDataStatus } from './mySiteDataStatus'; import type { MySiteDataDomain } from './mySiteDataDomain'; import type { MySiteDataTemplateId } from './mySiteDataTemplateId'; import type { MySiteDataPublishedAt } from './mySiteDataPublishedAt'; +import type { MySiteDataThumbnailUrl } from './mySiteDataThumbnailUrl'; /** * 내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site). @@ -32,5 +33,7 @@ export interface MySiteData { domain?: MySiteDataDomain; template_id?: MySiteDataTemplateId; published_at?: MySiteDataPublishedAt; + /** 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url). */ + thumbnail_url?: MySiteDataThumbnailUrl; needs_rebuild?: boolean; } diff --git a/solution/frontend/src/api/generated/model/mySiteDataThumbnailUrl.ts b/solution/frontend/src/api/generated/model/mySiteDataThumbnailUrl.ts new file mode 100644 index 0000000..16daca9 --- /dev/null +++ b/solution/frontend/src/api/generated/model/mySiteDataThumbnailUrl.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Web4Ai API + * OpenAPI spec version: 0.1.0 + */ + +export type MySiteDataThumbnailUrl = string | null; diff --git a/solution/frontend/src/pages/SitesPage.tsx b/solution/frontend/src/pages/SitesPage.tsx index 7c70a0f..862ab78 100644 --- a/solution/frontend/src/pages/SitesPage.tsx +++ b/solution/frontend/src/pages/SitesPage.tsx @@ -1,4 +1,4 @@ -import {useState} from 'react'; +import {useMemo, useState} from 'react'; import {Link, useNavigate} from 'react-router'; import { Building2, @@ -8,17 +8,22 @@ import { MoreHorizontal, Pencil, Plus, + Search, + SearchX, Stethoscope, UtensilsCrossed, Wand2, + X, } from 'lucide-react'; import {PlaceCategory, publishUrlString, SiteStatus} from '@o2o/shared'; import {changeStatus, PublishAction, useListMySites, type MySiteData} from '@/api'; import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; +import {Input} from '@/components/ui/input'; import {notify, notifyApiError} from '@/lib/notify'; import {PUBLISH_HOST} from '@/lib/site'; +import {cn} from '@/lib/utils'; // 발행본 주소는 PublishModal·CanvasView 와 같은 규칙이다 — 세 곳이 다른 주소를 말하면 안 된다. // ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌 @@ -32,24 +37,51 @@ const CATEGORY_ICON: Record<number, typeof Building2> = { [PlaceCategory.CLINIC]: Stethoscope, }; +/** + * 줄이 속하는 칸. **배지·필터·정렬이 전부 이 하나로 판정한다.** + * + * ★ 판정을 갈라 쓰면 조용히 틀린다 — '발행됨(1)' 을 눌렀는데 '내림' 배지가 낀 줄이 같이 + * 나오는 종류다. 건수까지 틀리므로 사장님은 목록을 못 믿게 된다. + * ★ 세 칸이 목록을 빈틈없이 나눈다. 하나라도 빠지면 '전체' 건수와 칸 건수의 합이 어긋난다. + */ +type SiteBucket = 'live' | 'draft'; +type SiteFilter = SiteBucket | 'all'; + +/** + * ★ 칸은 **둘**이다. 예전엔 '만드는 중'(사이트 행 없음)을 따로 뒀는데, 사장님에게 그 둘은 + * 같은 상태다 — "아직 안 나가 있다". `site_id` 가 있고 없고는 우리 DB 사정이지 사장님의 + * 구분이 아니고, 칸이 셋이면 34개가 어디 있는지 두 번 세게 된다. + */ +const FILTER_LABEL: Record<SiteFilter, string> = { + all: '전체', + live: '발행됨', + draft: '발행 전', +}; + +const FILTER_TABS: readonly SiteFilter[] = ['all', 'live', 'draft']; + +/** 나가 있는 것부터 본다 — 실측(계정 test): 35개 중 34개가 발행 전이라 1개가 묻힌다. */ +const BUCKET_ORDER: Record<SiteBucket, number> = {live: 0, draft: 1}; + +function bucketOf(row: MySiteData): SiteBucket { + return row.site_id && row.status === SiteStatus.PUBLISHED ? 'live' : 'draft'; +} + /** * 줄의 상태 배지. **사이트 상태(sites.status)만 본다** — 사업장 상태(places.status)는 * 수집 단계를 말하는 값이라 사장님이 궁금한 "지금 나가 있나"와 다르다. */ function statusBadge(row: MySiteData) { - if (!row.site_id) return {label: '만드는 중', variant: 'outline' as const}; - switch (row.status) { - case SiteStatus.PUBLISHED: - return row.needs_rebuild - ? {label: '수정됨 · 재발행 필요', variant: 'warning' as const} - : {label: '발행됨', variant: 'success' as const}; - case SiteStatus.SUSPENDED: - return {label: '중지', variant: 'outline' as const}; - case SiteStatus.UNPUBLISHED: - return {label: '내림', variant: 'outline' as const}; - default: - return {label: '발행 전', variant: 'default' as const}; + if (bucketOf(row) === 'live') { + return row.needs_rebuild + ? {label: '수정됨 · 재발행 필요', variant: 'warning' as const} + : {label: '발행됨', variant: 'success' as const}; } + // 같은 '발행 전' 이어도 **한 번 나갔다가 내린 것**은 말해 준다 — 되돌리는 일과 처음 내는 일은 + // 사장님이 할 행동이 다르다. 그 외(초안·수집 중)는 전부 '발행 전' 한 마디다. + if (row.status === SiteStatus.SUSPENDED) return {label: '중지', variant: 'outline' as const}; + if (row.status === SiteStatus.UNPUBLISHED) return {label: '내림', variant: 'outline' as const}; + return {label: '발행 전', variant: 'default' as const}; } /** 발행본이 실제로 열리는 주소. ★ 주소는 발행 전에 예약되므로 PUBLISHED 일 때만 연다 — 아니면 404 다. */ @@ -58,19 +90,135 @@ function publishedUrl(row: MySiteData): string | null { return publishUrlString(row.domain.split('.')[0], PUBLISH_HOST); } +/** + * 줄 뒤에 붙는 시각. **발행됐으면 발행일, 아니면 만든 날**이다. + * + * ★ 두 개를 같이 걸지 않는다. Wix·아임웹 목록이 시각을 한 칸만 쓰는 이유와 같다 — + * 목록에서 궁금한 건 "이게 언제 나갔나" 하나이고, 아직 안 나간 줄에만 만든 날이 의미가 있다. + * ★ 연도는 올해면 뗀다. 줄이 좁아 주소가 먼저 잘린다. + */ +function whenLabel(row: MySiteData): string { + const raw = row.published_at ?? row.created_at; + if (!raw) return ''; + const at = new Date(raw); + if (Number.isNaN(at.getTime())) return ''; + const now = new Date(); + const date = at.toLocaleDateString('ko-KR', { + ...(at.getFullYear() === now.getFullYear() ? {} : {year: 'numeric'}), + month: 'long', + day: 'numeric', + }); + return row.published_at ? `${date} 발행` : `${date} 만듦`; +} + +/** 정렬용 시각. whenLabel 이 고른 값과 같은 것을 쓴다 — 화면에 보이는 날짜와 순서가 갈라지면 안 된다. */ +function rowTime(row: MySiteData): number { + const raw = row.published_at ?? row.created_at; + if (!raw) return 0; + const at = new Date(raw).getTime(); + return Number.isNaN(at) ? 0 : at; +} + +/** + * 검색 비교용 정규화. **공백을 지운다** — 실측(계정 test)에 같은 상호 '버터브루' 가 4줄인데 + * 사장님은 '버터 브루' 로도 친다. 한국어 상호는 띄어쓰기가 원본마다 다르다. + */ +function normalizeText(value: string): string { + return value.toLowerCase().replace(/\s+/g, ''); +} + +/** 상호와 도로명 주소만 본다 — 상호가 겹치는 줄을 실제로 가르는 건 주소다. */ +function matchesQuery(row: MySiteData, needle: string): boolean { + if (!needle) return true; + return normalizeText(row.name).includes(needle) || normalizeText(row.road_address ?? '').includes(needle); +} + +/** + * 줄 앞의 그림. 발행에 성공한 사이트만 썸네일이 있고(sites.thumbnail_url), + * 없으면 업종 아이콘으로 떨어진다. + * + * ★ 주소에 `?v=<버전>` 이 붙어 있다 — 발행할 때마다 바뀐다(site_thumbnail.public_url). + * 그래서 <img> 에 캐시 무효화를 따로 걸지 않는다. 여기서 또 붙이면 발행하지 않은 + * 재방문에도 매번 새로 받는다. + * ★ 못 받으면 아이콘으로 되돌린다. 블롭이 지워졌거나 옛 주소인 줄에서 깨진 그림이 + * 뜨는 것보다 낫다. + */ +function SiteThumb({row, Icon}: {row: MySiteData; Icon: typeof Building2}) { + const [failed, setFailed] = useState(false); + const src = row.thumbnail_url; + + // ★ 비율은 **16:10 — 브라우저 창 비율**이다. 이 그림은 사이트 미리보기라 1:1 로 자르면 + // 위아래가 잘려 무슨 사이트인지 알아볼 수 없다(사진첩이 아니다). + // ★ 크기는 아임웹 내사이트 화면을 보고 키웠다(2026-09-08). 거기 썸네일도 줄 높이의 대부분을 + // 차지한다 — 같은 상호가 여러 줄일 때 그림이 유일한 구분자인데 작으면 있으나 마나다. + if (!src || failed) { + return ( + <div className="flex aspect-[16/10] w-full items-center justify-center border-b border-border bg-muted/60"> + <Icon className="size-7 text-muted-foreground/60" /> + </div> + ); + } + return ( + <img + src={src} + alt="" + loading="lazy" + onError={() => setFailed(true)} + className="aspect-[16/10] w-full border-b border-border object-cover" + /> + ); +} + /** * 내 사이트 — 로그인한 사장님의 홈이다. * * 흐름은 하나다: 위저드로 만든다 → 여기 생긴다 → 눌러서 에디터로 들어가 고친다 → 재발행한다. * ★ 그래서 줄을 누르면 에디터로 간다. 목록에 온 용건은 열에 아홉 "내 사이트 고치기"다. + * + * ★ 목록이 하는 일은 셋뿐이다 — ① 어느 게 어느 건지 가르고 ② 지금 상태를 말하고 ③ 여는 길을 준다. + * 방문자·주문 같은 숫자는 여기 넣지 않는다(Wix·아임웹도 사이트 안 대시보드에 둔다). */ export function SitesPage() { const navigate = useNavigate(); const {data, isLoading, isError, error, refetch} = useListMySites({size: 50}); const [busyId, setBusyId] = useState<string | null>(null); const [menuId, setMenuId] = useState<string | null>(null); + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState<SiteFilter>('all'); - const rows = data?.sites ?? []; + const rows = useMemo(() => data?.sites ?? [], [data]); + + // ★ 서버로 안 보낸다. 한 번에 50줄을 이미 다 받아 놓았고(실측 35줄), 서버 검색을 붙이면 + // 글자마다 왕복 + 디바운스 + 늦게 온 응답이 최신 결과를 덮는 경합까지 따라온다. + // → 50줄을 넘어 페이지가 생기는 날이 오면 그때는 서버가 맞다(이 필터는 첫 페이지만 본다). + const found = useMemo(() => { + const needle = normalizeText(query); + return rows.filter((row) => matchesQuery(row, needle)); + }, [rows, query]); + + // ★ 건수는 **검색 결과 위에서** 센다. 검색어를 친 뒤 "발행됨 0 · 만드는 중 3" 이 보여야 + // 찾는 게 어느 칸에 있는지 알 수 있다. 검색 전 건수를 그대로 두면 칸을 눌러 보고서야 안다. + const counts = useMemo(() => { + const tally: Record<SiteFilter, number> = {all: found.length, live: 0, draft: 0}; + for (const row of found) tally[bucketOf(row)] += 1; + return tally; + }, [found]); + + // 서버는 사업장 생성 역순으로만 준다(site_crud.list_owner_sites) — 발행 여부를 모른다. + // 나가 있는 것을 위로 올리는 건 여기서 한다. + const visible = useMemo(() => { + const picked = filter === 'all' ? found : found.filter((row) => bucketOf(row) === filter); + return [...picked].sort( + (a, b) => BUCKET_ORDER[bucketOf(a)] - BUCKET_ORDER[bucketOf(b)] || rowTime(b) - rowTime(a), + ); + }, [found, filter]); + + const isNarrowed = query.trim().length > 0 || filter !== 'all'; + + const resetView = () => { + setQuery(''); + setFilter('all'); + }; // 발행 내리기만 둔다. ★ 삭제 경로는 만들지 않는다 — 색인된 페이지를 404 로 만들면 // 그 자리를 다시 OTA 가 가져가고, 되돌릴 방법이 사장님에게 없다(sites.status 주석). @@ -135,53 +283,145 @@ export function SitesPage() { /> )} - {rows.length > 0 && ( - <ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-card"> - {rows.map((row) => { + {!isLoading && !isError && rows.length > 0 && ( + <div className="mb-3 flex flex-wrap items-center gap-2"> + <div className="relative min-w-56 flex-1"> + <Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" /> + <Input + type="search" + value={query} + onChange={(event) => setQuery(event.target.value)} + placeholder="상호나 주소로 찾기" + aria-label="상호나 주소로 찾기" + className="h-9 pr-8 pl-8" + /> + {query && ( + <button + type="button" + aria-label="검색어 지우기" + onClick={() => setQuery('')} + className="absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer text-muted-foreground transition-colors hover:text-foreground" + > + <X className="size-3.5" /> + </button> + )} + </div> + + {/* 건수를 배지가 아니라 칸 안에 붙인다 — "34가 어디 있나"는 칸을 눌러 보기 전에 보여야 한다. */} + <div className="flex items-center gap-0.5 rounded-md border border-border p-0.5"> + {FILTER_TABS.map((key) => ( + <button + key={key} + type="button" + aria-pressed={filter === key} + onClick={() => setFilter(key)} + className={cn( + 'h-8 cursor-pointer rounded px-2.5 text-xs font-medium transition-colors', + filter === key + ? 'bg-muted text-foreground' + : 'text-muted-foreground hover:text-foreground', + )} + > + {FILTER_LABEL[key]} + <span className="ml-1 tabular-nums opacity-60">{counts[key]}</span> + </button> + ))} + </div> + </div> + )} + + {/* ★ 처음 온 사람의 빈 화면과 섞지 않는다 — 사이트가 35개인데 "아직 없습니다" 라고 하면 + 사장님은 목록이 아니라 자기 사이트가 사라진 줄 안다. */} + {!isLoading && !isError && rows.length > 0 && visible.length === 0 && ( + <EmptyState + icon={SearchX} + title="조건에 맞는 사이트가 없습니다" + description={`가진 사이트 ${rows.length}개 중에 없습니다. 검색어를 줄이거나 다른 칸을 보세요.`} + action={ + <Button size="sm" onClick={resetView}> + 조건 지우기 + </Button> + } + /> + )} + + {visible.length > 0 && ( + /* ★ 줄이 아니라 카드다. 아임웹 내사이트 화면을 보고 바꿨다(2026-09-08) — 거기 썸네일은 + 줄 높이의 대부분을 차지한다. 사이트 목록에서 그림은 장식이 아니라 **유일한 구분자**라 + (실측: 같은 상호 '버터브루' 4줄) 작으면 있으나 마나다. + 비율은 16:10 — 사이트 미리보기라 브라우저 창 비율이어야 한다. 1:1 은 사진첩이지 사이트가 아니다. */ + <ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3"> + {visible.map((row) => { const Icon = CATEGORY_ICON[row.category] ?? Building2; const badge = statusBadge(row); const url = publishedUrl(row); + const isLive = bucketOf(row) === 'live'; const editHref = `/builder?placeId=${row.place_id}`; return ( - <li key={row.place_id} className="relative flex items-center gap-3 px-4 py-3.5 hover:bg-muted/40"> - <Icon className="size-4 shrink-0 text-muted-foreground" /> - - <Link to={editHref} className="min-w-0 flex-1"> - <div className="flex items-center gap-2"> - <span className="truncate text-sm font-semibold">{row.name}</span> - <Badge variant={badge.variant}>{badge.label}</Badge> - </div> - <p className="mt-0.5 truncate text-xs text-muted-foreground"> - {url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')} - </p> + /* ★ 카드는 **상태와 무관하게 같은 골격**이다. 예전엔 발행 전 카드에만 '할 일' + 줄이 하나 더 붙어서 같은 줄의 카드끼리 높이와 버튼 위치가 어긋났다(사장님 지적). + h-full + flex-col + mt-auto 로 액션 줄을 항상 카드 바닥에 붙인다. */ + <li + key={row.place_id} + className="group relative flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card transition-shadow hover:shadow-md" + > + <Link to={editHref} className="block"> + <SiteThumb row={row} Icon={Icon} /> </Link> - <div className="flex shrink-0 items-center gap-1.5"> - {url && ( - <a - href={url} - target="_blank" - rel="noopener noreferrer" - className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted" + {/* 배지는 그림 위에 얹는다 — 카드에서 상태는 제목보다 먼저 읽혀야 한다. */} + <Badge variant={badge.variant} className="absolute top-2.5 left-2.5 shadow-sm"> + {badge.label} + </Badge> + + <div className="flex flex-1 flex-col p-3.5"> + <Link to={editHref} className="block min-w-0"> + <p className="truncate text-sm font-semibold">{row.name}</p> + {/* ★ 나가 있는 카드만 주소를 진하게. 나머지의 이 자리는 "아직 없다"는 안내라 + 같은 색이면 34개의 안내문 사이에 진짜 주소가 묻힌다. */} + <p + className={cn( + 'mt-1 truncate text-xs', + isLive ? 'font-medium text-foreground' : 'text-muted-foreground', + )} > - <ExternalLink className="size-3.5" /> - 사이트 열기 - </a> - )} - <Button size="sm" onClick={() => navigate(editHref)}> - <Pencil /> - {row.site_id ? '편집' : '이어서 만들기'} - </Button> - <Button - size="icon" - variant="ghost" - aria-label="더보기" - isLoading={busyId === row.place_id} - onClick={() => setMenuId(menuId === row.place_id ? null : row.place_id)} - > - {busyId === row.place_id ? null : <MoreHorizontal />} - </Button> + {url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')} + </p> + {/* 같은 상호가 여럿일 때 가르는 값 — 주소와 시각. */} + <p className="mt-1 mb-3 truncate text-[11px] text-muted-foreground/70"> + {[row.road_address, whenLabel(row)].filter(Boolean).join(' · ')} + </p> + </Link> + + <div className="mt-auto flex items-center gap-1.5 border-t border-border pt-3"> + {/* 버튼 문구도 하나로 둔다 — '편집' 과 '이어서 만들기' 는 사장님이 할 일이 + 같은데(에디터를 연다) 글자만 달라 카드마다 폭이 들쭉날쭉했다. */} + <Button size="sm" className="flex-1" onClick={() => navigate(editHref)}> + <Pencil /> + 편집 + </Button> + {url && ( + <a + href={url} + target="_blank" + rel="noopener noreferrer" + className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted" + > + <ExternalLink className="size-3.5" /> + 열기 + </a> + )} + <Button + size="icon" + variant="ghost" + aria-label="더보기" + isLoading={busyId === row.place_id} + onClick={() => setMenuId(menuId === row.place_id ? null : row.place_id)} + > + {busyId === row.place_id ? null : <MoreHorizontal />} + </Button> + </div> </div> {menuId === row.place_id && ( @@ -193,7 +433,7 @@ export function SitesPage() { className="fixed inset-0 z-10 cursor-default" onClick={() => setMenuId(null)} /> - <div className="absolute right-4 top-12 z-20 w-44 rounded-md border border-border bg-card py-1 shadow-md"> + <div className="absolute right-3.5 bottom-12 z-20 w-44 rounded-md border border-border bg-card py-1 shadow-md"> <button type="button" disabled={row.status !== SiteStatus.PUBLISHED} @@ -210,6 +450,7 @@ export function SitesPage() { })} </ul> )} + </PageContainer> </AppShell> ); From f2dad657925369d6eb157ec07ed56f785fbce87f Mon Sep 17 00:00:00 2001 From: Mina Choi <mnchoi@o2o.kr> Date: Tue, 8 Sep 2026 13:02:11 +0900 Subject: [PATCH 18/18] =?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 `<title>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 ? (