diff --git a/AGENTS.md b/AGENTS.md index 9ec01f8..bf8062e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,6 @@ | 이 제품이 뭘 푸나 · **안 하기로 한 것** | [docs/PRODUCT.md](docs/PRODUCT.md) | | 어떻게 도나 · 앱 경계 | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | | **어느 표 어느 칸**에 담기나 · 값이 페이지까지 가는 길 | [docs/DATA_MODEL.md](docs/DATA_MODEL.md) | -| **다음에 뭘 만드나** (우선순위 P0~P4) | [docs/DEVELOPMENT_DIRECTION.md](docs/DEVELOPMENT_DIRECTION.md) | | 미결 사항 · 코드가 그걸 어떻게 격리했나 | [docs/DECISIONS.md](docs/DECISIONS.md) | | 최근에 뭘 왜 바꿨나 | [docs/DEVLOG.md](docs/DEVLOG.md) | | 서버에 올릴 때 | [docs/DEPLOY.md](docs/DEPLOY.md) | @@ -20,6 +19,9 @@ | **미니 블로그**(AI 자동 포스트) 기획 | [docs/MINI_BLOG.md](docs/MINI_BLOG.md) | | **사장님 에이전트**(카톡으로 관리) · 신원 연결 | [docs/AGENT.md](docs/AGENT.md) | | 카톡으로 **무엇을 시킬 수 있나** (운영자·CS 용) | [docs/AGENT_GUIDE.md](docs/AGENT_GUIDE.md) | +| **템플릿** 추가 · 렌더링 순서 · frontend/shared/site 역할 | [docs/TEMPLATES.md](docs/TEMPLATES.md) | +| 템플릿 **화면 규칙** (글자 · 간격 · 접기 · ✓ 표시) | [docs/TEMPLATE_DESIGN.md](docs/TEMPLATE_DESIGN.md) | +| **렌더링** 케이스별 흐름(정적 · 미리보기 · 발행)과 담당 파일 | [docs/RENDERING.md](docs/RENDERING.md) | --- diff --git a/README.md b/README.md index db1ef28..3f50fe5 100644 --- a/README.md +++ b/README.md @@ -55,13 +55,16 @@ postgres-init/ 스키마 DDL 의존 방향은 admin → solution 한 쪽뿐이다. 반대가 생기면 번들을 가른 의미가 사라진다. 근거는 [ARCHITECTURE.md 4절](docs/ARCHITECTURE.md). +> **`admin/` 은 지금 쓰지 않는다.** 개발자용 사이트·유저 관리는 solution 앱 안의 개발자 메뉴로 +> 가볍게 처리하고 있다(DEVLOG 2026-09-23). 우리가 따로 관리해야 할 만큼 사이트·운영 규모가 커지면 +> 그때 `admin/` 을 개발한다. 그 전에는 새 기능을 여기에 붙이지 않는다. + ## 문서 지도 | 문서 | 언제 읽나 | |---|---| | [docs/PRODUCT.md](docs/PRODUCT.md) | 이 제품이 뭘 푸는지 · **안 하기로 한 것**이 뭔지 | | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 발행 파이프라인 전체 · 두 앱과 한 백엔드의 경계 | -| [docs/DEVELOPMENT_DIRECTION.md](docs/DEVELOPMENT_DIRECTION.md) | v19 설계서 대비 격차 · **개발 우선순위(P0~P4)** | | [docs/DECISIONS.md](docs/DECISIONS.md) | 미결 사항과, 코드가 그걸 어떻게 격리해 뒀는지 | | [docs/DEPLOY.md](docs/DEPLOY.md) | 서버에 올릴 때 · 배포 후 재발행 절차 | | [docs/DATA_SOURCE_RESEARCH.md](docs/DATA_SOURCE_RESEARCH.md) | 어디서 콘텐츠를 가져올 수 있나 (실측 근거) | diff --git a/admin/backend/app.py b/admin/backend/app.py index be45cca..8b94ae7 100644 --- a/admin/backend/app.py +++ b/admin/backend/app.py @@ -1,10 +1,4 @@ -"""어드민 API. 솔루션 API(:9800)와 프로세스·포트가 갈린다. - -도메인 코드는 solution/backend 것을 PYTHONPATH 로 쓴다 — admin 전용 라우터가 0개라 -(전부 place·fact) 새로 쓰면 같은 테이블을 두 벌 구현하는 것뿐이다. -경로 접두어가 아니라 포트를 가른 이유: 접두어는 같은 프로세스라 사장님이 닿는 서버에 -내부 엔드포인트가 존재한다. 포트를 가르면 아예 없다. -""" +"""어드민 API.""" import time @@ -32,7 +26,7 @@ API_SERVER_START_TIME = GTime.UTCStr() @asynccontextmanager async def lifespan(app: FastAPI): - # 크론은 :9800 담당. 여기서도 돌리면 같은 시각에 중복 실행된다. + # 크론은 :9800 담당. yield await DB_SESSION_MNG.dispose_all() diff --git a/admin/backend/main.py b/admin/backend/main.py index 62c6810..f8c4318 100644 --- a/admin/backend/main.py +++ b/admin/backend/main.py @@ -1,5 +1,4 @@ -# 어드민 API 서버 (:9801). 근거는 app.py 주석. -# PYTHONPATH=../../solution/backend python main.py +# 어드민 API 서버 (:9801). import os diff --git a/admin/frontend/eslint.config.js b/admin/frontend/eslint.config.js index b669fb4..aeaed98 100644 --- a/admin/frontend/eslint.config.js +++ b/admin/frontend/eslint.config.js @@ -1,6 +1,4 @@ // 최소 게이트 — 전체 스타일 린트가 아니라, tsc 가 못 잡는 런타임 크래시 버그만 막는다. -// (negodata 보일러플레이트에서 이식. 도입 계기는 로컬 const 가 동명 import 를 가려 -// zustand 셀렉터가 TDZ 참조 → 프로덕션 크래시. 그 케이스는 tsc --noEmit 도 통과했다.) import tseslint from 'typescript-eslint'; import reactHooks from 'eslint-plugin-react-hooks'; @@ -18,7 +16,7 @@ export default tseslint.config({ // 훅 호출 순서 위반은 런타임 크래시라 error, deps 누락은 기존 코드가 많아 warn. 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'warn', - // 선언보다 위에서 변수를 쓰는 것(TDZ) 차단. 함수/타입 호이스팅은 안전하므로 허용. + // 선언보다 위에서 변수를 쓰는 것(TDZ) 차단. 'no-use-before-define': 'off', '@typescript-eslint/no-use-before-define': [ 'error', diff --git a/admin/frontend/src/app/provider.tsx b/admin/frontend/src/app/provider.tsx index 416c3b0..734582c 100644 --- a/admin/frontend/src/app/provider.tsx +++ b/admin/frontend/src/app/provider.tsx @@ -5,14 +5,7 @@ import {getAccessToken, me} from '@/api'; import {queryClient} from '@/lib/query-client'; import {toAuthUser, useAuthStore} from '@/stores/auth'; -/** - * 저장된 액세스 토큰으로 세션을 복구한다. - * - * ★ 사장님 앱과 결정적으로 다른 점: **여기서는 실패가 곧 차단이다.** - * 빌더는 로그인 없이도 돌아야 해서 인증 실패를 삼키지만(solution/frontend/app/provider.tsx), - * 내부 운영 화면은 전부 RequireAuth 뒤에 있다. 두 정책을 한 앱에 두면 실수가 늘 - * 느슨한 쪽으로 나기 때문에 앱을 갈랐다. - */ +/** 저장된 액세스 토큰으로 세션을 복구한다. */ function useRestoreSession() { const setUser = useAuthStore((s) => s.setUser); const finishRestore = useAuthStore((s) => s.finishRestore); @@ -31,7 +24,7 @@ function useRestoreSession() { setUser(toAuthUser(res)); }) .catch(() => { - /* 토큰이 죽었으면 비로그인 상태로 떨어뜨린다. RequireAuth 가 로그인으로 보낸다. */ + /* 토큰이 죽었으면 비로그인 상태로 떨어뜨린다. */ }) .finally(() => { if (alive) finishRestore(); diff --git a/admin/frontend/src/app/router.tsx b/admin/frontend/src/app/router.tsx index 6a7e5b5..09a8af3 100644 --- a/admin/frontend/src/app/router.tsx +++ b/admin/frontend/src/app/router.tsx @@ -10,20 +10,14 @@ import {PlaceDetailPage} from '@admin/pages/PlaceDetailPage'; import {PlaceListPage} from '@admin/pages/PlaceListPage'; import {SeoAuditPage} from '@admin/pages/SeoAuditPage'; -/** - * ★ 내부 메뉴는 여기 있다. AppShell(사장님 앱 소유)에 두면 이 경로 이름들이 - * 사장님 번들에 문자열로 남는다 — 앱을 가른 이유가 사라진다. - */ +/** 내부 메뉴는 여기 있다. */ const ADMIN_NAV: NavItem[] = [ {to: '/places', match: '/places', label: '사업장', icon: Building2}, {to: '/local-content', match: '/local-content', label: '지역 콘텐츠', icon: CalendarDays}, {to: '/reviews', match: '/reviews', label: '이용 후기', icon: MessageSquareQuote}, ]; -/** - * 내부 운영 화면. **전부 RequireAuth 뒤에 둔다** — 예외를 하나 두는 순간 - * 그 예외가 기본값이 된다. 사장님 앱과 앱을 가른 이유가 이 규칙을 지키기 위해서다. - */ +/** 내부 운영 화면. */ export const router = createBrowserRouter([ // selfServe=false: 내부 운영 계정은 우리가 만들어 준다 — 가입 링크도 구글 로그인도 두지 않는다. {path: '/login', element: }, diff --git a/admin/frontend/src/lib/solutionUrl.ts b/admin/frontend/src/lib/solutionUrl.ts index 3dc1c86..0aa1c88 100644 --- a/admin/frontend/src/lib/solutionUrl.ts +++ b/admin/frontend/src/lib/solutionUrl.ts @@ -1,7 +1,4 @@ -/** - * 사장님 앱 오리진. 빌더는 다른 오리진이라(:3000 vs :3002) react-router Link 로 두면 - * admin 안에서 404 다. 절대 URL + 새 탭으로 연다. - */ +/** 사장님 앱 오리진. */ const ORIGIN = import.meta.env.VITE_SOLUTION_URL ?? 'http://localhost:3000'; export function builderUrl(params?: {placeId?: string; isNew?: boolean}): string { diff --git a/admin/frontend/src/pages/LocalContentPage.tsx b/admin/frontend/src/pages/LocalContentPage.tsx index 8f53c55..70f98ec 100644 --- a/admin/frontend/src/pages/LocalContentPage.tsx +++ b/admin/frontend/src/pages/LocalContentPage.tsx @@ -9,8 +9,7 @@ import {customFetch} from '@/api/mutator/custom-fetch'; import {toast} from 'sonner'; type Status = 1 | 2 | 3; -// LocalContentType — common/enums.py 와 값을 맞춘다. WEATHER(1) 은 사업장 발행본에 실시간으로 -// 붙는 별도 흐름이라 이 화면에서는 다루지 않는다(services/local_content_service.get_weather). +// LocalContentType — common/enums.py 와 값을 맞춘다. type ContentType = 2 | 3 | 4; type LocalContent = { @@ -107,8 +106,7 @@ export function LocalContentPage() { } catch { toast.error('발행하지 못했습니다.'); } }; const sync = async () => { - // ★ 주변정보는 업장 단위(place_contents)다 — 지역 코드가 아니라 사업장 id 로 받는다. - // 이 화면의 목록은 아직 지역 캐시(local_contents)를 보여준다. 업장별 목록 화면은 다음 작업이다. + // 주변정보는 업장 단위(place_contents)다 — 지역 코드가 아니라 사업장 id 로 받는다. const placeId = window.prompt('사업장 ID(place_id)를 입력하세요. 사업장 목록 주소의 /places/ 뒤 값입니다.')?.trim(); if (!placeId) return; setSyncing(true); @@ -118,7 +116,6 @@ export function LocalContentPage() { festivals?: number; attractions?: number; restaurants?: number; changed?: boolean; }>({url: `/v1/admin/local-content/place/${placeId}/sync`, method: 'POST'}); if (res.result?.success === false) throw new Error(res.msg); - // ★ 여행코스(코스)는 2026-09-08부터 수집하지 않는다(반경을 넓혀도 데이터가 거의 없었다) — 표기에서 뺀다. const summary = `축제 ${res.festivals ?? 0} · 관광지 ${res.attractions ?? 0} · 맛집 ${res.restaurants ?? 0}건`; if (!res.changed) { toast.info(`바뀐 내용이 없습니다 (${summary}, TourAPI 원문 그대로).`); diff --git a/admin/frontend/src/pages/PlaceDetailPage.tsx b/admin/frontend/src/pages/PlaceDetailPage.tsx index be63602..d7f27fb 100644 --- a/admin/frontend/src/pages/PlaceDetailPage.tsx +++ b/admin/frontend/src/pages/PlaceDetailPage.tsx @@ -45,8 +45,7 @@ export function PlaceDetailPage() { const transition = useTransitionFact({ mutation: { onSuccess: (res) => { - // ★ 백엔드는 거절도 200 + result.success=false 로 준다 — 여기서 안 걸러내면 - // 저장되지 않은 값이 '확인됨'으로 보인다. + // 백엔드는 거절도 200 + result.success=false 로 준다 — 여기서 안 걸러내면 저장되지 않은 값이 '확인됨'으로 보인다. if (res.result?.success === false) { notifyApiError({data: res}, '허용되지 않는 상태 전이입니다.'); return; @@ -177,8 +176,7 @@ export function PlaceDetailPage() {
- {/* ★ 재수집은 오른쪽 열 맨 위다. 이 화면에 온 사장님의 두 가지 용건이 - "확인 대기 값을 처리한다"(왼쪽)와 "값을 다시 가져온다"(여기)라서다. */} + {/* 재수집은 오른쪽 열 맨 위다. */} diff --git a/admin/frontend/src/pages/PlaceListPage.tsx b/admin/frontend/src/pages/PlaceListPage.tsx index dd870e5..ae26cd3 100644 --- a/admin/frontend/src/pages/PlaceListPage.tsx +++ b/admin/frontend/src/pages/PlaceListPage.tsx @@ -35,16 +35,7 @@ const STATUS_LABEL: Record = { [PlaceStatus.SUSPENDED]: '중지', }; -/** - * 사업장 목록 — 이 제품의 허브다. - * - * 흐름은 하나뿐이다: - * 빌더(위저드)로 만든다 → **여기 생긴다** → 여기서 에디터로 들어가 고친다 → 재발행하면 HTML 이 다시 구워진다. - * - * ★ 그래서 줄을 누르면 사업장 상세가 아니라 **에디터**로 간다. 목록에 온 사장님의 - * 용건은 열에 아홉 "내 사이트 고치기"다. fact 를 하나씩 확인하는 상세 화면은 - * [정보 확인] 으로 따로 둔다 — 발행 게이트에 걸렸을 때 가는 곳이다. - */ +/** 사업장 목록 — 이 제품의 허브다. */ export function PlaceListPage() { const [search, setSearch] = useState(''); const [deletingId, setDeletingId] = useState(null); @@ -155,7 +146,7 @@ export function PlaceListPage() { > {STATUS_LABEL[place.status] ?? '알 수 없음'} - {/* ★ verified_at 이 NULL 이면 수집·발행 진입 금지. 목록에서 바로 보이게 둔다. */} + {/* verified_at 이 NULL 이면 수집·발행 진입 금지. */} {place.verified_at ? ( diff --git a/admin/frontend/src/pages/ReviewModerationPage.tsx b/admin/frontend/src/pages/ReviewModerationPage.tsx index aebaa50..26733d0 100644 --- a/admin/frontend/src/pages/ReviewModerationPage.tsx +++ b/admin/frontend/src/pages/ReviewModerationPage.tsx @@ -7,12 +7,7 @@ import {Card, CardContent} from '@/components/ui/card'; import {customFetch} from '@/api/mutator/custom-fetch'; import {toast} from 'sonner'; -/** - * 이용 후기 — 손님 글은 이미 화면에 올라가 있다. 이 화면은 **내리는** 자리다(사후 대응). - * - * ★ 사람 검수를 앞에 두지 않는다(2026-09-16 대표: "그냥 뜨게 하지"). 기계 필터를 통과하면 - * 그 자리에서 공개되고, 문제 글을 여기서 내린다. 내리면 손님 화면에서도 바로 빠진다. - */ +/** 이용 후기 — 손님 글은 이미 화면에 올라가 있다. */ type Review = { review_id: string; place_id: string; diff --git a/admin/frontend/vite.config.ts b/admin/frontend/vite.config.ts index 20a2c29..c392ffc 100644 --- a/admin/frontend/vite.config.ts +++ b/admin/frontend/vite.config.ts @@ -3,13 +3,7 @@ import react from '@vitejs/plugin-react'; import path from 'path'; import {defineConfig} from 'vite'; -/** - * 내부 운영 화면. 사장님 앱(solution/frontend)과 번들이 갈린다. - * - * `@` 를 이 앱이 아니라 사장님 앱 src 로 겨눈다 — 내부 화면이 쓰는 API·UI·수집 배선이 - * 거기 한 벌만 있고 그 파일들끼리도 `@/...` 로 서로를 부른다(자기 src 로 잡으면 TS2307 14건). - * 이 앱 고유 파일은 `@admin`. 의존 방향은 admin → solution 한 쪽뿐이다. - */ +/** 내부 운영 화면. */ export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 73b51e1..e7ed3e2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -126,7 +126,7 @@ o2o-web4ai/ │ ├─ backend/ FastAPI + 워커 · 이 레포의 유일한 백엔드 │ ├─ frontend/ 빌더 (위저드 + 에디터 + 발행 게이트) │ ├─ site/ 발행 정적 사이트 (SSR 엔트리 + 프리렌더) -│ └─ shared/ frontend·site·백엔드 계약 (SitePayload · slug · 토큰) +│ └─ shared/ frontend·site·백엔드 계약 (템플릿 목록 · SitePayload · slug · 토큰) │ ├─ admin/ 우리 — 전체 사이트 운영 │ ├─ backend/ 어드민 API 진입점(:9801). 도메인 코드는 solution/backend @@ -142,6 +142,8 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를 ### `frontend`(빌더)와 `site`(발행물)는 요구가 정반대다 +세 폴더가 각각 무엇을 하는지, 템플릿이 그려지는 순서는 [TEMPLATES.md](TEMPLATES.md)에 있다. + 같은 `solution/` 안에 있지만 빌드를 나눈 이유가 이 표다. 보일러플레이트는 순수 CSR 이라 발행 사이트에 그대로 쓰면 크롤러가 `
` 만 읽고 떠난다. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 9b83f95..77cdb1c 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -70,7 +70,7 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 · [에디터] 템플릿 고르기 ─────────────→ sites.template_id - 색·서체·섹션 순서/on-off ──→ sites.theme (JSONB) + 색·섹션 순서/on-off ───────→ sites.theme (JSONB) 섹션 내용 편집 ────────────→ site_sections.data (JSONB, 섹션당 1행) 주변정보 숨김·거리 ────────→ place_area_refs.hidden / distance_m 미리보기 ──────────────────→ GET /v1/place/{id}/site/preview @@ -225,8 +225,8 @@ Gemini 가 쓰고, 곡은 Suno 가 붙인다. | 칸 | 무엇 | 왜 서버에 두나 | |---|---|---| -| `template_id` | 사장님이 고른 템플릿 키 | 서버는 **해석하지 않고 보관·반환만** 한다. 템플릿 목록은 프론트가 소유하므로, 서버가 검증하면 템플릿을 늘릴 때마다 백엔드를 고쳐야 한다 | -| `theme` (JSONB) | 색·서체·**섹션 순서/on-off/배리에이션** | 브라우저에만 두면 발행 잡이 읽을 곳이 없어 업종 기본으로 굽고, 고른 디자인과 발행본이 갈린다. 컬럼으로 펼치지 않는 이유는 항목이 늘 때마다 마이그레이션이 따라오기 때문 | +| `template_id` | 사장님이 고른 템플릿 id(`simple` `magazine` `retro` `paper`). NULL 이면 업종 기본 템플릿 | 템플릿 목록은 `solution/shared/src/data/templates.json` 한 파일이고, 서버도 그 파일을 읽어 업종이 못 쓰는 값은 저장·발행 때 거절한다([TEMPLATES.md](TEMPLATES.md)) | +| `theme` (JSONB) | 색·**섹션 순서/on-off** (서체·모서리 같은 모양은 템플릿이 정하므로 저장값을 쓰지 않는다) | 브라우저에만 두면 발행 잡이 읽을 곳이 없어 업종 기본으로 굽고, 고른 디자인과 발행본이 갈린다. 컬럼으로 펼치지 않는 이유는 항목이 늘 때마다 마이그레이션이 따라오기 때문 | | `status` | 1 DRAFT / 2 REVIEW / 3 PUBLISHED / 4 SUSPENDED / 5 UNPUBLISHED | ★ 해지는 **물리 삭제가 아니라 상태 전이**다 — 색인된 페이지를 갑자기 404 로 만들지 않는다 | | `current_version_id` | 지금 나가 있는 버전 | | | `thumbnail_url` | 쇼케이스 카드 그림 | ★ **발행에 성공한 뒤에만** 채운다. 스크린샷이 아니라 그 사이트의 대표 사진(og:image)이다 — 헤드리스 브라우저는 영구 금지 | diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 6d22c82..292fbba 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -226,7 +226,7 @@ 골라도 그 자리가 비었다. 그래서 서버가 채운다. **2026-09-10 — 일력(`daily`)을 여섯 번째로 넣는다.** 이 종류만 프롬프트가 빌더 -(`canvas/dataSpec.ts`)에 손으로 적혀 있어 `shared/section-prompts.ts` 에 없었고, 서버는 +(당시 `canvas/dataSpec.ts`, 지금은 `builder/sections/dataSpec.ts`)에 손으로 적혀 있어 `shared/section-prompts.ts` 에 없었고, 서버는 그 종류의 존재 자체를 몰랐다. 렌더러에는 '오늘의 한 장' 탭 자리가 있고 '옛 항구' 템플릿 설명도 일력을 약속하는데 채우는 쪽만 없어서, 그 탭은 손으로 넣은 시안에만 있었다. → 종류 목록의 단일 출처는 `STORY_KINDS` 하나다. 뽑는 스크립트도 그 상수를 읽는다 @@ -394,7 +394,7 @@ JWT 를 쓰지 않는 이유는 JWT 가 서명만 검증하고 **소비를 못 key 만 보면 사장님 입력(근거 없음)을 놓치고, 키워드만 보면 "주차 및 와이파이" 처럼 묶인 문항의 한쪽을 놓친다. - 답은 **문의 안내뿐**이다("…은 전화(…)로 문의해 주시면 안내해 드립니다"). 값·가능 여부를 적지 않는다. 업종 시드 FAQ 가 "숯과 그릴 세트(25,000원)" 같은 가공의 값을 사이트에 내보낸 일을 반복하지 않기 위해서다 - (`frontend … canvas/variants/faq/useFaqList.ts` 주석). + (`frontend … canvas/variants/faq/useFaqList.ts` 주석, 이 파일은 2026-09-28 배치 고르기와 함께 지웠다). - 출처는 `SourceType.TEMPLATE`(5). 재생성 때 LLM 생성분과 함께 내리고 다시 고른다. fact 에는 쓸 수 없다. - ★ **fact 가 0건이어도 20개다.** 근거가 없으면 LLM 은 부르지 않고(환각·유료 호출 방지) 채우기만 돈다. 그 경로는 API 키도 필요 없다. 예전에는 `start_copy` 가 `FAQ_UNGROUNDED` 로 잡을 만들지 않아 FAQ 가 0개였다 — diff --git a/docs/DEVELOPMENT_DIRECTION.md b/docs/DEVELOPMENT_DIRECTION.md deleted file mode 100644 index 04c1a09..0000000 --- a/docs/DEVELOPMENT_DIRECTION.md +++ /dev/null @@ -1,214 +0,0 @@ -# Web4AI 개발 방향 — v19 설계서와 현재 구현 비교 - -> 기준일: 2026-08-31 -> 비교 대상: `Web4AI_SW설계서_및_개발일정_v19.pdf`(22쪽)와 이 저장소의 현재 코드·문서 -> 목적: 설계서를 그대로 복제하는 것이 아니라, 현재 제품에서 **유지할 결정**, **방향을 다시 정할 결정**, **추가할 개발 항목**을 구분한다. - -설계서 표지는 파일명과 달리 `v18 · 2026.08.30`으로 표기되어 있다. 아래에서는 전달받은 파일을 편의상 “v19 설계서”라고 부르되, 계약·일정 확정 전 문서 버전부터 확인해야 한다. - ---- - -## 1. 결론 - -현재 프로젝트는 설계서 전체의 축소판이 아니라, 설계서의 **Site AEO(A1~A8) 일부를 소상공인용 제품으로 먼저 구현한 별도 MVP**에 가깝다. - -- 현재 강점은 `사업장 확인 → 허용된 소스 수집 → fact 승인 → 근거 기반 문구 생성 → 정적 HTML 발행 → IndexNow`가 실제 코드와 테스트로 연결되어 있다는 점이다. -- 가장 큰 공백은 **Brand AEO 전체(B1~B9)**, **규제 검사(A4)**, **소유권 검증(A1)**, **원본 변경·AI 크롤러 재방문 추적(A9)**이다. -- 가장 큰 방향 충돌은 **타겟 업종**, **Playwright 크롤링**, **배포 도메인**, **마이크로서비스·공통 인프라**다. 이 항목은 “미구현”으로 보고 바로 만들면 안 되고 제품·법무·운영 결정을 먼저 내려야 한다. -- 권장 방향은 현 구조를 버리고 5계층/2엔진으로 즉시 재작성하는 것이 아니다. 현재 시스템을 **Site AEO MVP 기준선**으로 유지하고, Brand AEO를 경계가 분명한 모듈로 붙인 뒤 부하와 조직 규모가 실제 분리를 요구할 때 서비스로 분리한다. - -### 현재 범위의 대략적인 위치 - -| 설계서 영역 | 현재 판단 | -|---|---| -| Site AEO A1~A9 | **부분 구현** — A3·A5·A6·A7 일부와 A8 중심 | -| Brand AEO B1~B9 | **미구현** — 준비도 자체 점수만 있으며 실제 AI 응답 측정은 없음 | -| 운영 콘솔 15개 화면 | **부분 구현** — 사업장·빌더·지역정보·SEO/AEO 준비도 중심 | -| 계약 A~G / BFF | **미구현** — 화면이 FastAPI 를 직접 호출 (BFF 없음) | -| 25테이블 append-only Fact Graph | **다른 모델로 구현** — 승인 후보/노출값 중심의 key-value fact 모델 | -| 8개 스프린트 일정 | **현재 코드에 바로 적용 불가** — 이미 구현된 것과 방향 충돌 항목이 섞여 있어 재산정 필요 | - ---- - -## 2. 방향이 다른 부분 - -아래는 단순히 덜 만든 기능이 아니라, 설계서와 현재 프로젝트가 서로 다른 결정을 내린 항목이다. - -| 항목 | v19 설계서 | 현재 프로젝트 | 권장 판단 | -|---|---|---|---| -| 제품 범위 | Site AEO + Brand AEO 이원 플랫폼 | 상호명 기반 소상공인 정적 홈페이지 생성·발행 | 현재 제품을 Site AEO MVP로 명시하고 Brand AEO 확장 여부를 별도 마일스톤으로 승인 | -| 1차 업종 | 숙박, 법무법인, 성형외과 | 숙박, 카페, 음식점, 관광체험 | **반드시 사업 결정 필요.** 법률·의료를 넣으면 데이터 스키마뿐 아니라 규제·승인·감사 체계가 선행되어야 함 | -| 입력 방식 | 고객 원본 사이트를 depth 3·최대 200페이지 크롤 | 공식 API, 사용자 확정 URL, 정적 HTML; 플랫폼 우회 수집 금지 | 현재의 출처·동의·robots 원칙 유지. 대규모 원본 사이트 수집은 별도 제품 모드로 분리 | -| 동적 크롤링 | 정적 우선, Playwright 폴백 | 봇 탐지 우회로 변질될 수 있어 HeadlessAdapter 미등록·금지 | 포괄적인 Playwright 폴백은 채택하지 않음. 소유권이 검증된 고객 도메인에만 허용할지 법무·보안 결정 후 제한적으로 검토 | -| 발행 도메인 | 고객 도메인 서브패스 권장, 서브도메인 차선 | 기본 `web4ai.o2osolution.ai/s/`, custom domain 경로 미완성 | 설계서 방향이 검색 권위 측면에서 더 적합. 고객 도메인 연결·소유권 검증을 우선 과제로 추가 | -| 백엔드 구조 | Site/Brand 엔진, BFF, 엔진별 DB 분리 | 코드 한 벌 + 진입점 둘(:9800 사장님 / :9801 내부), 단일 PostgreSQL | 청중별 분리는 포트로 끝냈다. 엔진별 물리 분리는 트래픽·팀 소유권 근거가 생긴 뒤 수행 | -| 작업 인프라 | Temporal, Redis, Celery 등 공통 인프라 | PostgreSQL 잡 큐 + lease + dead-letter | 현재 DB 큐 유지. 동일 책임의 인프라를 중복 도입하지 않음. 장기 워크플로 보상·분산 추적 요구가 확인될 때 Temporal 재평가 | -| Fact Graph | 엔티티·predicate·snapshot, append-only | 업종 스키마 기반 key-value fact, 후보/노출/이력 상태 | 현재 모델은 발행 안전성에 적합. Brand 측정 재현성에 필요한 snapshot과 entity 관계만 점진적으로 확장 | -| 점수 | Site AEO Score + 실제 4개 AI 엔진 기반 AVS | 내부 데이터 기반 SEO/AEO **준비도** 점수 | 이름과 의미를 분리 유지. 실제 측정 전 현재 점수를 AVS/가시성 점수라고 부르지 않음 | -| 운영 사용자 | Reviewer/Owner 권한과 15개 통합 화면 | USER/OWNER/DEVELOPER, 사장님 앱(:3000)과 내부 콘솔(:3002)이 갈려 있다 | 규제 기능 도입 전 Reviewer 역할·서버 계산 `allowed_actions` 추가 | -| 이미지 | 원본 사진·영상이 EEAT 근거 | 이미지 호스팅을 현재 non-goal로 두고 외부 URL 사용 | 저작권 결론과 소유자 업로드 저장소가 먼저. 크롤 이미지 재게시를 전제로 개발하지 않음 | - ---- - -## 3. 설계서 항목별 구현 차이 - -### 3-1. Site AEO A1~A9 - -| 단계 | 현재 상태 | 코드 근거 | 추가할 것 | -|---|---|---|---| -| A1 사이트 진단·소유권 검증 | **일부** | 사업장 동일 업소 확인과 `verified_at`, SEO 진단은 있으나 DNS TXT/meta/well-known 검증은 없음 | 도메인 소유권 challenge, 만료·재검증, 발행 차단 정책 | -| A2 크롤·추출 | **일부** | collector registry, `StaticHtmlAdapter`, TourAPI, 네이버 장소 조회, 사용자 확정 링크 | 허용 도메인용 crawl run/document 기록, 원본 hash, 제한·재시도·수집 보고서 | -| A3 Fact Graph | **부분 구현** | `facts`, 업종 스키마, 출처·신뢰도·상태 전이, 승인 후보 모델 | source URL의 selector/snippet, entity 관계, 측정용 불변 snapshot | -| A4 규제·과장 검사 | **기초만 존재** | 생성 문구의 과장·근거 없는 숫자/시설 검사는 있으나 업종별 법규 3단 분류와 승인 감사는 없음 | 외부화된 규칙, SAFE/REVIEW/BLOCK 판정, 규칙 버전, 근거 snippet, Reviewer 승인 로그 | -| A5 AEO 콘텐츠 생성 | **구현** | Gemini Text, 소개문·meta·FAQ, 근거 fact key, ground check | 질문은행과 생성 페이지의 연결, 질문형 콘텐츠 단위의 버저닝 | -| A6 JSON-LD | **구현** | 업종별 JSON-LD, FAQPage, Breadcrumb, WebPage, sameAs | 법률·의료 도입 시 타입·자격·저자 스키마 확장 | -| A7 3-way 일치성 | **부분 구현** | JSON-LD↔표시 텍스트 검증, 생성문↔fact 근거 검사, publish gate | 원본↔발행본 SimHash 중복도, 모든 사실 문장의 fact ID 역참조 보고서 | -| A8 배포 | **대부분 구현** | 프리렌더 정적 HTML, canonical, sitemap, robots, llms.txt, IndexNow, nginx/Azure 경로 | 고객 도메인 서브패스·서브도메인 연결, TLS/DNS 자동화, Search Console 제출 자동화 여부 | -| A9 모니터링·변경 감지 | **미구현** | `AI_CHECK` enum 만 있고 worker handler·보고 모듈 없음. 표(`ai_check_results`)는 한 번도 안 써서 마이그레이션 0006 이 뗐다 — 붙일 때 다시 만든다 | CDN 크롤러 로그 수집, 원본 hash 비교, fact 만료/재검토, 재생성 큐 | - -### 3-2. Brand AEO B1~B9 - -현재 `seo_audit.py`의 점수는 “발행 준비도”다. 설계서의 Brand AEO처럼 ChatGPT·Gemini·Claude·Perplexity 응답을 정기 호출하여 브랜드 언급·인용·순위·사실성을 측정하지 않는다. - -필요한 기능은 다음 순서가 적절하다. - -1. **B1 질문은행**: 업종×지역×의도 질문, 버전, 활성 세트, 고객 편집 이력 -2. **B2 측정 스케줄**: 주기, 엔진, 반복 횟수, 비용 상한, 중지 조건 -3. **B3~B4 엔진 어댑터와 원문 보존**: 모델·버전·프롬프트·응답·citation 정규화 -4. **B5 분석**: 브랜드 alias 언급, 인용 URL, 추천 위치, 감성, fact snapshot 대조 -5. **B8 이원 점수**: 원계열, 반복 평균, 신뢰구간, MA4를 모두 보존하고 준비도 점수와 별도 표시 -6. **B6~B7 개선 루프**: EEAT 결손과 외부 인용원을 제안하되, 자동 발행하지 않고 fact 확인/A4/A7을 재통과 -7. **B9 리포트**: 주간·월간 리포트, 모델 교체 마커, 비용과 데이터 결손 표시 - -100문항×4엔진×3회라는 설계서 기본값은 테넌트당 주 1,200회 호출이다. 현재 제품 원가 상한인 **사이트당 약 $1**과 충돌할 가능성이 높으므로, 구현 전 모델별 실측 단가와 파일럿 질문 수를 다시 계산해야 한다. 초기에는 10~20개 핵심 질문, 1~2개 엔진, 반복 3회로 시작하고 통계적 유효성과 비용을 함께 측정하는 편이 안전하다. - -### 3-3. 콘솔·계약·데이터 - -- 내부 콘솔(`admin/frontend`)에는 사업장 목록/상세, 지역 콘텐츠, SEO 진단이 있고 빌더는 사장님 앱(`solution/frontend`)에 있다. 설계서의 질문 빌더·규제 승인·AI 퍼포먼스·랭킹·인용출처·정기 리포트 화면은 없다. -- 화면이 API 를 직접 호출한다. 설계서의 BFF, 서비스 JWT 교환, 섹션별 부분 실패, 계약 A~G는 없다. -- DB에는 현재 17개 ORM 모델이 있으며 설계서의 `document`, `predicate_def`, `entity`, `fact_snapshot`, `compliance_rule`, `review`, `publication_question`, `index_state`, `regeneration_request`, `event_outbox`, `audit_log` 등에 해당하는 완성 모델은 없다. -- 현재 fact는 수정 잠금과 후보 이력을 보존하지만, 설계서가 요구하는 전체 append-only 불변식·스냅샷 재현성 모델과 같지는 않다. - -계약 A~G를 한 번에 33개 REST/7개 이벤트로 만드는 것보다, 먼저 아래 세 계약만 버전 고정하는 것을 권장한다. - -1. `FactSnapshot`: 측정 당시의 발행 사실을 재현할 수 있는 읽기 전용 계약 -2. `QuestionSet`: 생성 대상과 측정 대상을 같은 question ID로 연결하는 계약 -3. `Publication`: 발행 URL과 question ID를 연결해 인용 성과를 귀속하는 계약 - -이 세 계약이 있어야 Brand AEO 결과가 단순한 “브랜드가 나왔다”를 넘어 “어떤 질문을 겨냥한 어떤 페이지가 인용됐다”까지 설명할 수 있다. - ---- - -## 4. 권장 개발 우선순위 - -### P0 — 개발 전에 확정할 결정 - -- **제품 범위**: Site AEO 소상공인 MVP를 유지할지, 법률·의료와 Brand AEO를 이번 제품 범위에 포함할지 -- **1차 파일럿**: Stay 머뭄 1곳 우선인지, 3업종 동시인지 -- **도메인 전략**: 고객 서브패스 / 고객 서브도메인 / 플랫폼 공용 경로의 지원 우선순위 -- **크롤 정책**: 소유권 검증 고객 도메인의 JS 렌더링 허용 범위. 플랫폼 robots·봇 차단 우회 금지는 유지 -- **이미지 권리**: 소유자 업로드만 허용할지, 기존 플랫폼 사진 재게시를 허용할지 -- **비용 예산**: Brand 측정의 테넌트당 주간 호출·금액 상한 -- **문서 버전**: 전달 파일의 파일명 v19와 표지 v18 불일치 해소 - -### P1 — 현재 Site AEO를 설계서 수준으로 닫기 - -1. 도메인 소유권 검증과 만료 시 발행 차단 -2. crawl run/document와 원본 hash 저장 -3. A7 SimHash 중복도 검사 및 fact 역참조 리포트 -4. A9 원본 변경 감지, fact 만료/검토, AI 크롤러 방문 로그 -5. 고객 도메인 연결, TLS/DNS 운영 절차 -6. 서버 계산 `allowed_actions` (앱 경계 분리는 2026-08-31 완료) - -완료 기준은 “페이지가 만들어진다”가 아니라, **소유권이 확인된 원본에서 출처가 남는 fact를 만들고 두 게이트를 통과해 고객 도메인에 발행되며, 변경과 재방문을 관측할 수 있다**는 것이다. - -### P2 — 규제 업종을 넣는 경우에만 선행 - -1. Reviewer 역할과 승인 워크벤치 -2. 외부화된 업종별 규칙과 버전 관리 -3. SAFE / NEEDS_EVIDENCE / PROHIBITED 3단 판정 -4. 규칙 ID·근거 snippet·판정 모델·승인자·시각을 남기는 감사 로그 -5. 성형외과 사전심의 상태와 자격/면허 fact 모델 -6. 법률·의료 전문가의 규칙 승인 및 변경 절차 - -A4가 완성되기 전에는 법무법인·성형외과 콘텐츠 자동 발행을 열지 않는다. - -### P3 — Brand AEO 최소 측정 루프 - -1. 질문은행과 publication-question 연결 -2. fact snapshot -3. 1~2개 AI 엔진 어댑터와 응답 원문·모델 버전 저장 -4. 언급·인용 URL·추천 위치·사실성 분석 -5. 반복 측정, 신뢰구간, MA4, 비용 집계 -6. 읽기 전용 퍼포먼스·인용출처 화면 - -처음부터 자동 EEAT 재생성까지 닫지 말고, 먼저 **같은 질문을 반복 측정했을 때 지표가 의사결정에 쓸 만큼 안정적인지** 검증한다. - -### P4 — 개선 폐루프와 운영 확장 - -- EEAT 결손 → 고객 확인 요청 / 재생성 요청 분기 -- 재생성 요청의 A4·A7 재통과 -- 정기 리포트와 외부 채널 전략 -- BFF의 섹션별 부분 실패와 서비스 JWT -- 데이터·트래픽·팀 소유권이 임계에 도달하면 Site/Brand 저장소 및 배포 단위 분리 - ---- - -## 5. 재작성하지 않고 유지할 현재 구현 - -설계서와 다르더라도 아래는 현재 제품에 맞고 이미 안전장치가 있으므로 유지하는 편이 낫다. - -- PostgreSQL 기반 잡 큐의 원자적 claim, lease, dedupe, dead-letter -- `VERIFIED`/`CORRECTED`만 발행하고 재수집 후보가 정정값을 덮지 않는 상태 모델 -- 백엔드는 payload만 만들고 프론트 프리렌더러가 정적 HTML을 생성하는 경계 -- JSON-LD와 화면값 불일치 시 발행을 막는 게이트 -- 외부 API 키가 없어도 해당 어댑터만 비활성화하는 구성 -- robots.txt와 약관을 우회하지 않는 수집 원칙 -- 현재 SEO/AEO 점수를 “준비도”로 명시하는 정직한 표현 - ---- - -## 6. 일정 재구성 제안 - -설계서의 S1~S8은 신규 구축 기준이라 현재 저장소에 그대로 적용하면 이미 끝난 기반 작업을 반복하고, 미결 정책을 코드로 먼저 굳히게 된다. 다음과 같이 게이트 중심으로 다시 잡는다. - -| 마일스톤 | 목표 | 종료 조건 | -|---|---|---| -| M0 방향 확정 | 범위·업종·도메인·크롤·비용 결정 | P0 결정 기록과 승인 | -| M1 Site 완결 | A1/A7/A9 공백과 고객 도메인 보완 | 소유권→발행→변경감지 E2E 통과 | -| M2 규제 게이트 | 법률·의료를 할 경우 A4 구축 | 전문가 승인 룰셋과 감사 가능한 차단/승인 | -| M3 Visibility 파일럿 | 질문은행·snapshot·최소 엔진 측정 | 반복 측정의 비용·분산·인용 검출 정확도 보고 | -| M4 개선 루프 | 측정 결과를 안전한 재생성 요청으로 연결 | 고객 확인 또는 A4/A7 재통과 후 발행 | -| M5 플랫폼화 | 콘솔/BFF/서비스 분리 | 실제 트래픽·팀 소유권 기준 충족 시에만 수행 | - -주차 추정치는 P0의 업종 수, AI 엔진 수, 외부 전문가 검토 가능일이 정해진 뒤 산정한다. 특히 3개 업종 동시 개발과 4개 엔진×3회 측정을 전제로 한 기존 8스프린트 일정은 현재 인력·비용 정보 없이 확정 일정으로 취급하면 안 된다. - ---- - -## 7. 바로 만들 백로그 - -| 우선순위 | 에픽 | 대표 산출물 | -|---|---|---| -| 1 | 소유권 검증 | challenge 테이블/API, DNS/meta/well-known 검증기, 만료 정책, 발행 게이트 | -| 2 | 수집 재현성 | crawl run, document hash, source selector/snippet, 변경 비교 | -| 3 | 일치성 강화 | SimHash, fact 역참조 커버리지, 실패 사유 UI | -| 4 | 발행 관측 | crawler visit/index 상태, CDN 로그 적재, 재수집/재생성 조건 | -| 5 | 고객 도메인 | 서브패스/서브도메인 연결, canonical·sitemap 검증, TLS/DNS 운영 | -| 6 | 측정 계약 | question set, fact snapshot, publication-question 연결 | -| 7 | Visibility 파일럿 | 엔진 어댑터, 원문 로그, mention/citation/position/factuality 분석, 비용 상한 | -| 8 | 운영 화면 | 소유권·수집·게이트·발행 상태부터 추가, 이후 질문/성과/인용 화면 | -| 조건부 | 규제 업종 | 규칙 저장소, Reviewer, 승인 워크벤치, 감사 로그, 사전심의 | - ---- - -## 8. 관련 현재 문서 - -이 문서는 비교와 향후 방향만 다룬다. 현재 제품 원칙과 구현 상세는 중복해서 관리하지 않는다. - -- 제품 범위와 non-goal: [PRODUCT.md](PRODUCT.md) -- 현재 수집·생성·발행 흐름: [COLLECTION_SEO_AEO_FLOW.md](COLLECTION_SEO_AEO_FLOW.md) -- 법무·데이터·작업 큐 결정: [DECISIONS.md](DECISIONS.md) -- 데이터 소스 실측: [DATA_SOURCE_RESEARCH.md](DATA_SOURCE_RESEARCH.md) -- 배포와 도메인 운영: [DEPLOY.md](DEPLOY.md) -- 외부 API 비용: [API_USAGE.md](API_USAGE.md) - diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 5562774..5f9c360 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -1,5 +1,18 @@ # 개발 일지 +무엇을 왜 바꿨는지 날짜순(새 것이 위)으로 요약한다. 결론·배경은 각 문서가 단일 출처고, 여기에는 +**나중에 같은 실수를 막아 주는 것**(결정의 이유·밟은 함정·실측값)만 남긴다. +2026-09-29에 요약본으로 다시 썼다. 원문 전체는 git 히스토리(이 파일의 09-29 이전 버전)에 있다. + +## 2026-09-30 — 템플릿 검수 · 코랄 · 미니멀 · 솔숲 추가 + +- 한국 펜션 사이트(코랄트리 · 바다동화)를 참고해 `coral` · `minimal`, 디자인 스킬 시안에서 `pine`(솔숲). +- 화면 규칙을 [TEMPLATE_DESIGN.md](TEMPLATE_DESIGN.md) 로 뽑았다 — 섹션은 **제목 위 · 내용 아래**, + ‘가능’은 ✓ 목록, 같은 탭을 다시 눌러도 맨 위로, 제목↔설명↔내용 간격. +- ★ 좌우 분할(제목 왼쪽 · 내용 오른쪽)은 내용이 한두 줄이면 왼쪽이 텅 비어 버렸다. +- ★ 템플릿 넷이 같은 Noto Sans KR 이라 다 비슷해 보였다 — 한글 제목 글꼴을 템플릿마다 다르게 배정. +- 간격 검사는 제목 위 여백만 보고 있어서 **설명↔내용 0px** 을 놓쳤다. 형제 요소 사이 간격을 전부 재도록 바꿨다. + ## 2026-09-28 — 한 발화에 여러 가지 (+ 실배포에서 잡은 인자 버그) **① 인자가 모델에 닿지 않던 것** — 배포 후 실모델로 찍어 보고 잡았다. 도구 선택은 6/6 @@ -69,1719 +82,173 @@ monkeypatch 해서 그 층을 건너뛰니 전부 초록이었다. **검증** — `test_agent_runtime` 26 passed(구성 7건 추가). 전체 `864 passed / 53 failed` 이고 그 53 은 이번 변경 전과 같다. -## 2026-09-22 — 카톡 5초 벽을 콜백으로 넘는다 - -실제 카톡에서 "시설 편의에서 바비큐 이용 문구 빼줘" 가 **"확인하는 데 시간이 조금 걸리네요"** -로 끝났다. 타임아웃이었다. - -★ **작은 표본으로 잰 수치를 상한 근거로 삼은 것이 틀렸다.** 개발 중 잰 1.3~2.4초는 업종 필드 -두 개짜리 장난감 프롬프트였고, 진짜 요청에는 필드 43개 + fact 수십 개가 실린다. -"여유가 있다" 고 적어 둔 판단이 실사용 첫날에 깨졌다. - -**고친 방법** — 오픈빌더 콜백(스킬 타임아웃 5초, 콜백 주소 1분·1회): -`userRequest.callbackUrl` 이 실려 오면 `{"useCallback": true}` 로 **즉답**하고, 백그라운드에서 -답을 만든 뒤 그 주소로 따로 POST 한다. 콜백이 꺼져 있으면 예전처럼 동기(4.5초 상한). - -★ 콜백 전송 실패는 **재시도하지 않는다** — 1회용 주소라 두 번째 POST 는 거절되고, 사장님에게는 -이미 "확인하고 있어요" 가 가 있다. - -★ 오픈빌더 스킬 설정에서 **콜백 사용을 켜야** 이 경로가 열린다. 안 켜면 코드가 있어도 -`callbackUrl` 이 안 와서 동기 경로로만 돈다 — 조용히 예전처럼 동작한다. - -**검증** — `test_kakao_webhook.py` 24 passed(콜백 3건 추가: 즉답 형식·콜백 전송·전송 실패). - -## 2026-09-22 — 카톡 대화에 홈페이지 목록·가게 고르기 - -실제로 붙여 보니 빠진 것이 드러났다(사장님 지적): 연결은 됐는데 **어느 홈페이지를 다루는 -대화인지 화면이 말해 주지 않았다.** 가게가 하나면 말없이 자동 선택돼 더 모호했다. - -- 연결 직후 목록을 보여준다. 하나면 그 이름과 발행 여부를, 여럿이면 **바로가기 버튼**으로 고르게. -- 목록 줄에 **발행 여부**를 적는다 — 안 그러면 고친 것이 손님에게 보이는 줄 안다. -- "목록"·"가게 바꿔줘" 등으로 **언제든 돌아와 바꾼다.** ★ 이 경로는 LLM 을 부르지 않는다 — - 대화가 막혔을 때 처음 찾는 길이라 늘 통해야 하고, 목록 보기에 돈을 쓸 이유가 없다. -- 목록은 `list_my_sites` 를 쓴다(사업장 목록이 아니라). `/sites` 화면이 같은 이유로 그걸 쓴다 — - 사장님이 알아야 하는 건 "가게가 있다" 가 아니라 "발행돼 있나" 다. - -**검증** — `test_kakao_webhook.py` 21 passed(목록·전환 4건 추가). -전체 `845 passed / 53 failed`, 53 은 이번 변경 전과 같다. - -## 2026-09-22 — 카카오 채널 웹훅(4단계) - -카카오톡 채널이 준비돼 웹훅을 만들었다. **런타임은 한 줄도 안 바뀌었다** — 채널을 모르게 -만들어 둔 것이 여기서 값을 했다. 새로 생긴 것은 형식 변환(`kakao_bot.py`)과 대화 상태 -(`channel.py`)뿐이다. - -**★★ 인증 — 오픈빌더는 서명을 주지 않는다** -URL 만 알면 누구나 때릴 수 있고 `userRequest.user.id` 를 위조하면 **그 사장님 행세**를 한다. -1단계에서 만든 신원 연결이 통째로 무의미해지는 자리다. 공유 시크릿(헤더 `X-Agent-Secret`, -`hmac.compare_digest`) + 선택적 `KAKAO_BOT_ID` 대조로 막고, 시크릿이 없으면 **엔드포인트가 -404** 다 — 401 은 "여기 뭔가 있다" 를 알려 준다. - -**빌더 화면과 다른 것 셋** — 나머지는 `runtime.chat()` 그대로다. -1. 로그인 토큰이 없다 → 발화자 키로 사장님을 찾는다. ★ **토큰을 발급하지 않는다** - (카톡 경로에서 JWT 가 나오면 그게 곧 권한 탈취 경로다) -2. `place_id` 가 URL 에 없다 → 대화에서 고르고 `current_place_id` 에 기억. - ★ 여럿인데 안 정해졌으면 **추측하지 않고 되묻는다** -3. 확인을 되돌려 줄 프론트가 없다 → 서버가 pending 을 들고 있는다(0022). - ★ `pending_expires_at`(3분)이 없으면 **한참 뒤의 "네" 한 마디에 묵은 발행이 돈다** - -**5초 벽** — `DEADLINE_SEC=4.0`. 넘기면 카카오가 끊어 말없이 실패하는 봇이 되므로 안내로 -끊는다. 도구 선택 실측이 1.3~2.4초라 여유가 있다. 콜백은 오픈빌더 지원 여부 확인 뒤에. -어떤 실패도 **200 + 안내 문구**다 — 메신저에서는 500 도 침묵으로 보인다. - -**밟은 것** — `DB_SESSION_MNG.execute_lambda` 는 **람다 반환값을 그대로** 준다(CRUD 관례가 -`(ErrorType, 값)`). 우리 람다가 객체만 돌려주자 언패킹에서 TypeError 가 났고, 라우터가 모든 -예외를 삼키는 구조라 화면에는 "지금은 처리할 수 없어요" 한 줄만 보였다 — 원인이 안 보이는 종류다. - -**검증** — `test_kakao_webhook.py` 17 passed(시크릿·위조·만료·가게 되묻기·5초·형식 누출). -전체 `841 passed / 53 failed` 이고 그 53 은 이번 변경 전과 같다. - -## 2026-09-22 — 에이전트 대화창 다시 염(기본 켜짐) - -카카오톡 채널의 통신사 인증이 끝나 보류를 푼다(사장님 지시). `AGENT_CHAT_ENABLED` 기본값을 -`0` → `1` 로 돌렸다. **코드는 어제도 오늘도 그대로다** — 닫고 여는 일이 커밋을 되짚는 일이 -되면 안 된다는 어제 판단이 하루 만에 값을 쳤다. - -★ 기본을 켜도 **LLM 키가 없으면 안 열린다**(`runtime.is_configured` 가 스위치와 키를 둘 다 -본다). 키 없는 환경에서 켜 둔 채 잊어도 "눌러도 안 되는 입구" 가 생기지 않는다. - -★ 카카오 연결 카드는 아직 감춰져 있다 — `KAKAO_CHANNEL_PUBLIC_ID` 미설정. -채우면 코드는 발급되지만 **소비할 웹훅(4단계)이 없어 연결이 완성되지 않는다.** -채널 '채팅' 과 '챗봇(오픈빌더 스킬)' 은 다른 기능이고, 웹훅이 붙는 쪽은 후자다. - -**검증** — `test_agent_runtime`(스위치 테스트를 새 기본값에 맞춰 갱신)·`test_kakao_link` 34 passed. - -## 2026-09-21 — 에이전트 화면 보류: 설정으로 닫는다(코드는 그대로) - -카카오톡 채널 개설이 **법인폰 본인인증**에 걸려 보류됐다(사장님 지시: "이 작업은 여기서 딱 -보류하고, 사용못하게 대화 할 수 있는 부분을 숨겨줘"). 채널이 없으면 대화창은 사장님에게 -**어디에도 닿지 않는 입구**이고, 열려 있으면 "되는 기능" 으로 오해한다. - -- `AGENT_CHAT_ENABLED` 신설(기본 `0`). `runtime.is_configured()` 가 스위치와 LLM 키를 **둘 다** - 본다 — 화면을 우회해 API 를 직접 불러도 `AGENT_NOT_CONFIGURED` 다. -- `AgentChatDock` · `KakaoChannelCard` 둘 다 조건 미충족이면 `return null` 로 통째로 감춘다. - 연결 카드는 `connection_enabled=false` 가 기준이라 설정을 채우면 그대로 다시 나타난다. -- ★ **코드를 지우지 않았다.** 되돌릴 때 커밋을 되짚지 않고 값 둘만 채우면 된다. - -★ Threads 카드와 판단이 갈린 것이 맞다 — 저쪽은 '자리는 두고 버튼만 죽인다'(사장님이 곧 쓸 수 -있는 기능이라 존재를 알려야 했다), 이쪽은 언제 열릴지 말해 줄 수 없어 감춘다. - -**검증** — `test_agent_runtime`(스위치 테스트 2건 추가)·`test_kakao_link` 34 passed. -`npm run lint` 통과. - -## 2026-09-21 — 사장님 에이전트 2단계: 도구 레지스트리 · 런타임 · 빌더 채팅창 - -**왜 카카오톡보다 이걸 먼저 만드나** -런타임이 채널을 모르므로, 채널·챗봇 심사 없이 **에이전트 전체를 빌더 화면에서 검증**할 수 있다. -웹훅 핸들러 안에 에이전트를 짜면 빌더에서 같은 걸 못 쓰고 심사가 끝나야 무엇 하나 확인되지 않는다. -카톡은 나중에 붙는 두 번째 입구다 — `runtime.chat()` 을 그대로 부른다. - -**한 일** -- `services/agent/tools.py` — 도구 넷과 등급 셋(`READ`·`REVERSIBLE`·`SEMI`). - `get_site_status`·`list_facts`·`set_fact`·`publish`. -- `services/agent/runtime.py` — 발화 → 도구 선택(LLM 1콜) → 실행 → 응답. 채널을 모른다. -- `services/prompts/agent.py` — LLM 네 겹 규약(`services/llm/__init__.py`)대로 프롬프트만 여기. -- `router/v1/agent/chat.py`, 프론트 `features/agent/AgentChatDock.tsx`(`/sites` 우하단). - -**세 가지를 모델에게 맡기지 않았다** -1. **등급** — 확인이 필요한지는 레지스트리가 못 박는다. 응답 스키마에 그 칸 자체가 없고 - 도구 목록에도 등급을 싣지 않는다. 모델이 정하면 프롬프트에 끼어든 한 줄이 확인을 건너뛴다. -2. **결과 문구** — 도구가 만든다. 모델이 쓰면 **하지 않은 일을 했다고 말할 수 있고** - 사장님에게는 사실로 보인다. 모델 문장은 '되묻기' 에만 쓴다. -3. **key** — `set_fact` 의 key 는 업종 스키마가 최종 판정이다. 모델이 없는 key 를 지어낸다. - -**확인(SEMI) 한 바퀴** — `publish` 는 고르기만 하고 실행하지 않는다. 화면이 [네, 해주세요] 를 -띄우고, 누르면 `{confirm:{tool,args}}` 로 다시 온다. ★ 서버는 그 값을 믿지 않는다 — 도구는 -레지스트리에서 다시 찾고 인자는 도구가 다시 검증한다. 확인 절차가 검증을 건너뛰는 구멍이 되면 안 된다. - -**값을 고치면 재발행 안내를 함께 낸다** — fact 는 바뀌어도 사이트는 안 바뀐다. -이 한 줄이 빠지면 사장님은 반영된 줄 알고 확인하러 갔다가 옛 값을 보고 "고장났네" 가 된다. - -**검증** — `test_agent_runtime.py` 17 passed. 그중 하나는 `tools.py` 소스에서 `crud` 직접 호출이 -없는지 실제로 검사한다(주석이 아니라 코드로 못 박는 자리). 테스트는 LLM 을 monkeypatch 해서 -실제 모델을 부르지 않는다. `npm run lint` 통과. - -## 2026-09-21 — 사장님 에이전트 1단계: 카카오톡 채널 신원 연결 - -**왜 이것부터인가** -카카오 채널이 주는 발화자 식별자는 **채널 단위 익명 키**라 우리 `user_id` 와 관계가 없다. -다른 엔드포인트는 전부 `place_crud.get_place(s, owner_user_id, place_id)` 로 소유자 범위를 -지키는데, 채널에서 온 발화에는 그 `owner_user_id` 를 줄 근거가 없다 — 매핑이 없으면 -**채널 진입점만 소유자 범위 밖**에 놓이고 채널에 말을 건 아무나가 남의 가게를 고친다. - -**한 일** -- `owner_kakao_links`(0021 + init.sql) — 부분 유니크 셋. 그중 `uq_kakao_link_channel_key` - (한 카카오 계정 = 한 사장님)가 없으면 "어느 가게 이야기냐" 가 대화가 아니라 DB 에서 갈라진다. -- `services/kakao_link_service.py` — 발급·소비·조회·해제. 일회성은 코드 값이 아니라 - `WHERE status='PENDING'` CAS 한 문장이 보장한다. 실패는 전부 같은 에러(`KAKAO_LINK_CODE_INVALID`)다 — - "없는 코드"·"만료"·"시도 초과" 를 구분해 답하면 6자리의 유효성을 밖에서 탐색할 수 있다. -- 코드는 sha256 만 저장한다. 사장님이 손으로 치는 짧은 값이라 평문이면 DB 를 읽는 쪽이 곧 - 연결 권한을 갖는다. 글자에서 `0·O·1·I·L` 을 뺐다 — 잘못 읽어 실패하면 원인이 화면에 안 보인다. -- `router/v1/agent/kakao.py` 셋(`link`·`link/code`·`link/disconnect`), 전부 `no-store`/`no-referrer`. -- 프론트 `features/agent/` — `/sites` 의 Threads 카드 옆에 나란히. 연결은 사람 단위라 같은 자리다. -- `config/agent_config.py` 를 `social_config.py` 와 **일부러 갈랐다** — SNS 게재는 되돌릴 수 없는 - 대외 발화, 에이전트는 자기 사이트를 고치는 창구. 승인 강도도 보관하는 것도 다르다. - -**★ 일부러 안 만든 것 — 코드 소비 엔드포인트** -코드를 소비하는 쪽은 채널 웹훅이고, 그 웹훅은 자체 서명 검증을 갖춘 뒤에야 열 수 있다. -검증 없는 공개 소비 경로를 먼저 만들면 누구나 6자리를 대입해 남의 계정에 자기 카톡을 붙인다 — -이 표가 막으려던 바로 그 일이다. `redeem()` 은 서비스 함수로만 두고 라우터에 붙이지 않았다. - -**검증** — `test_kakao_link.py` 15 passed. 전체 백엔드 `780 passed / 50 failed`인데, -그 50건은 **같은 커밋 이전(HEAD)에서도 동일하게 50건**이다(워크트리로 대조 확인) — -`test_gemini*`·`test_site_theme`·`test_search_console_service` 등 기존 이슈이고 이번 변경과 무관하다. -`npm run lint`(frontend·admin·site) 통과. - -## 2026-09-17 — 미니 블로그 — 지금 생성하기에 구간(시작~끝) 지정, 실배포 E2E 로 잡은 버그 1건 - -**한 일** -- **"지금 생성하기"가 구간을 받는다**(사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 - 정해야하지 않을까" → "캘린더 UI로 날짜받게"). `POST .../post/generate?start=&end=` - (`blog_jobs.generate_range`) — 개별 생성과 같은 이유로 재고 상한(`REFILL_BELOW`)을 안 보고, - 이미 글이 있는 날짜는 LLM 호출 없이 건너뛰고, 소재가 떨어지면 그 자리에서 멈춘다. 응답에 - `requested`/`created` 를 같이 줘서 "N일 중 M일만 채웠습니다"를 보여줄 수 있게 했다. 프론트는 - 버튼을 누르면 시작·끝일을 `` 두 개로 받는 다이얼로그가 뜬다. -- 기존 `blog_jobs.generate_now`(재고 상한 기반, "다음 빈 날부터 순서대로")는 삭제하고 - `generate_range` 로 교체 — 호출부가 이 엔드포인트 하나뿐이라 하위호환 어댑터 없이 바로 바꿨다. - -**실배포로 E2E 를 돌리다 잡은 버그 — `blog_service.generate_one` 의 죽은 import** -사장님이 "테스트하고 결과 알려줘"로 시켜서 로컬 docker 를 재배포하고 실제 API 로 전체 플로우를 -돌렸더니(회원가입→사업장→발행 시드→생성→개별생성→승인), "지금 생성하기"가 500 으로 죽었다. -원인: `from services.external.gemini_text import DEFAULT_TEXT_MODEL, is_configured` — -`DEFAULT_TEXT_MODEL` 은 애초에 그 모듈에 있던 적이 없다(LLM 공급자를 gemini/openai 로 가르는 -리팩터로 `services/external/gemini_text.py` 가 "소개문·FAQ 조립" 전용으로 바뀌면서, 모델 -상수·`is_configured`는 `services/llm/gemini.py`(`DEFAULT_MODEL`)로 옮겨갔다). pytest 는 이 -함수를 통째로 monkeypatch 하는 테스트뿐이라 이 import 자체가 실행된 적이 없어 26 passed 로도 -안 잡혔다 — **"단위 테스트가 초록"과 "실제로 돈다"는 다른 것**이라는 걸 이번에 실측으로 -확인했다. 고침: `services.llm.gemini` 에서 `DEFAULT_MODEL`·`is_configured` 를 가져오도록 -import 한 줄만 수정. - -**검증** — `test_blog_post.py`·`test_blog_owner.py` 27 passed(신규: 구간 생성 성공/거절). -전체 백엔드 `753 passed`(기존에 깨져 있던 `test_gemini*`·`test_search_console_service.py` -44건은 이번 변경과 무관 — LLM 공급자 전환 관련 별개 이슈, 앞선 라운드에서도 확인). `npm run -build -w @o2o/frontend` 통과. 로컬 docker 재배포 후 실제 API 로 회원가입→생성→개별생성→ -구간생성→승인→BUILD 잡 큐잉까지 end-to-end 확인(진짜 Gemini 호출 포함, 브라우저 확장이 -연결되지 않아 화면 클릭 대신 API 레벨로 돌렸다). → [MINI_BLOG.md](MINI_BLOG.md) - -## 2026-09-17 — 미니 블로그 — 탭 3개→2개로 되돌림, 생성 이력에 모델명, 빈 날짜 개별 생성 - -**한 일** -- **탭을 3개(이번 주·달력·생성 이력)에서 2개(블로그·생성 이력)로 되돌렸다.** 지난 라운드에서 - 카로셀·달력을 각자 탭으로 쪼갠 게 오독이었다(사장님 지시: "탭을 왜 이번주 달력 이렇게 - 나누고 지랄이야 내가 언제그러라그랬어 달력위에 이번주 카드들 보여주라고 했지") — 원래 - 요청은 "달력 위에 카로셀"이지 "카로셀 따로, 달력 따로"가 아니었다. 생성 이력만 별도 탭으로 - 남긴다(`BlogPostsPage.tsx` `Tab = 'main' | 'history'`). -- 달력 칸 배지 문구 "메일 발송됨" → **"발송완료"**(사장님 지시: "달력에 발송완료 된거는 - 되었다고 적으라고", `publishBadge`). -- **생성 이력에 어느 모델을 썼는지 추가**(사장님 지시: "생성이력도 상세하게 기록해놓으셈 - 어느 모델썼는지 등등"). 새 컬럼을 늘리는 대신 `place_posts.generation_meta`(jsonb) 한 - 칸에 `{"model": "..."}` 로 담는다(사장님 지시: "Jsonb 하나팟거 컬럼", - `migrations/0020_place_posts_generation_meta.sql`). `blog_service.generate_one()` 반환값을 - `str | None` → `tuple[str, str] | None`(본문, 모델명)으로 바꾸고, `PostCRUD.generation_batches` - 가 회차별 대표 모델(`MAX(generation_meta->>'model')`)을 같이 뽑는다. -- **빈 날짜 하나만 콕 집어 생성**(사장님 지시: "그리고 개별적으로 새로 만들수있게 해줘"). - `POST /v1/place/{place_id}/post/generate-one?date=`(`PostService.generate_for_date` → - `blog_jobs.generate_one_for_date`) — 재고 상한(`REFILL_BELOW`)을 안 본다, 콕 집은 날짜라 - 상한이 끼어들 자리가 아니다. 프론트는 달력에서 **오늘 이후의 빈 칸**만 누르면 그 날짜로 - 요청하고, 성공하면 그 자리에서 모달을 연다(`Calendar` `onGenerateDay`/`generatingDay`). - 지난 날짜 칸은 클릭을 막는다. - -**밟은 함정 — ORM 객체를 commit 뒤까지 들고 있으면 detached 로 깨진다** -`PostCRUD.add_one`을 처음엔 ORM 객체(`place_posts(**row)`)를 그대로 돌려주게 짰다. -`execute_lambda_write`는 `func(s)` 실행 뒤 **commit까지 하고** 값을 돌려주므로, -호출측이 그 객체의 속성(`post_id` 등)을 읽는 시점엔 세션이 이미 끝나 `DetachedInstanceError` -가 날 자리였다. `post_id`·`status`(둘 다 Python 쪽 `default`)는 `flush()` 직후엔 이미 -채워져 있으므로, **flush 직후 세션이 살아있을 때** 값만 plain dict 로 뽑아 돌려주게 고쳤다 -— ORM 객체 자체를 세션 밖으로 내보내지 않는다. - -**검증** — `test_blog_post.py`·`test_blog_owner.py` 26 passed(신규 3건: 개별 생성 성공·날짜 -중복 실패·소유권 스코프). 전체 백엔드 `753 passed`(기존에 깨져 있던 `test_gemini*`· -`test_search_console_service.py` 44건은 이번 변경과 무관 — LLM 공급자 전환 관련 별개 이슈). -`npm run build -w @o2o/frontend` 통과. → [MINI_BLOG.md](MINI_BLOG.md) - -## 2026-09-17 — 미니 블로그 메일 — 승인 즉시 처리 + 수정 자동 로그인, 화면 탭 3개로 - -**한 일** -- 메일 승인 링크: GET 이 확인 화면 없이 **즉시 승인**(`router/v1/site/post.py`). 메일 - 프리페치에 노출된다는 걸 알고도 사장님이 택한 것 — POST `/approve`, GET/POST - `/v1/site/post/edit`(공개 편집 화면) 전부 삭제, `PostService.edit` 도 같이 지웠다. -- 메일 수정 링크: 이제 **로그인 흐름**이다. `CreateDayPassToken`(그날 자정 KST 까지만 - 사는 접근 토큰, `router/v1/validator/dependencies.py`)을 실은 - `/blog?placeId=&postId=&auto=` 로 간다. 빌더 앱이 그 토큰으로 로그인해 편집 모달을 - 바로 연다. -- **승인·수정 링크 둘 다 그날 자정(KST) 만료**로 통일(`blog_service.issue_token`, 예전 - 14일 → 자정). 그 뒤엔 로그인해서 빌더 앱에서 처리한다. -- 신규 엔드포인트: `GET .../post/{post_id}`(메일 수정 링크 전용 단건 조회), - `GET .../post/history`(생성 이력 — 언제 몇 건, 새 컬럼 없이 `created_at` 회차로 묶음). -- `BlogPostsPage.tsx` 를 탭 셋으로 재구성 — **이번 주 · 달력 · 생성 이력**. 카로셀 카드를 - 누르면 그 자리에서 고치는 대신 모달을 연다(미리보기용 `PostPreviewCard` 와 실제 편집용 - `PostCard` 분리). 달력 칸엔 발행완료/발행실패에 **메일 발송됨** 배지를 추가했다(크론잡이 - 실제로 돌았다는 확인). 이전 달/월/다음 달을 달력 탭 안, 달력 바로 위로 옮겼다. - -**밟은 함정 — 세션 복구보다 늦게 로그인시키면 이미 늦다** -`BlogPostsPage` 안에서 `auto` 토큰으로 로그인시켰더니 "메일온거 클릭했더니 로그인하라고 -뜨는데?" — `RequireAuth` 는 라우트 렌더링 시점에 `isRestoring`/`user` 를 보고 그 자리에서 -`/login` 으로 튕긴다. 페이지 컴포넌트는 그 판정 *이후에만* 마운트되므로, 컴포넌트 안의 -`useEffect` 로 로그인시키는 건 이미 늦다. `auto` 파라미터 처리를 세션 복구 -(`app/provider.tsx` `useRestoreSession`) 안으로 옮겨서 고쳤다 — JWT `sub` 클레임을 -그대로 디코드해(`lib/jwt.ts`, 서명 검증은 이미 서버가 함) `useAuthStore` 를 채운다. - -**밟은 함정 — raw SQL 로 timestamptz 에 naive UTC 를 바인딩하면 로컬 시간대로 샌다** -자정 만료로 정밀해지자 테스트 3개가 "이미 만료됨"으로 죽었다. 원인: 테스트 시더가 -`text()` 로 `token_expires_at` 에 naive datetime(`GTime.UTC()` 류)을 직접 바인딩하는데, -컬럼 타입 정보가 없는 raw 바인딩은 asyncpg 가 **드라이버 프로세스의 로컬 시스템 시간대**로 -해석한다 — 이 개발 머신은 KST(UTC+9) 라 9시간이 밀렸다. 예전엔 14일짜리 만료값이라 9시간 -밀려도 부호가 안 바뀌어 안 드러났을 뿐이다. ORM 경로(`update()`/`insert()`)는 컬럼의 -`DateTime(timezone=True)` 프로세서를 타서 이 문제가 없다 — 실제 운영 코드(`mark_sent`)는 -전부 ORM 이라 안전했다. 고침: 테스트 시더에서 바인딩 직전에 `.replace(tzinfo=timezone.utc)` -로 명시(`tests/test_blog_post.py`). **raw text() 로 timestamptz 컬럼에 naive datetime 을 -바인딩하는 코드를 다시 보면, 반드시 이 함정을 의심한다.** - -**검증** — `test_blog_post.py`·`test_blog_owner.py` 23 passed. `npm run build -w -@o2o/frontend` 통과. mnchoi@o2o.kr 로 실제 메일 미리보기 발송 확인(가짜 place/post 라 -링크 자체는 동작하지 않음, 형식만 확인). → [MINI_BLOG.md](MINI_BLOG.md) - -## 2026-09-17 — 미니 블로그 빌더 화면 — 카로셀은 일주일치·달력은 모달, scheduled_date NULL 백필 - -**한 일** -- `GET /v1/place/{place_id}/post/upcoming?days=7` 신설(`PostService.list_upcoming`) — 카로셀은 - 이제 브라우징 중인 달과 무관하게 **항상 오늘부터 7일치**만, 날짜 오름차순으로 본다. - 기존 `list_for_place` CRUD 를 월 경계 대신 (오늘, 오늘+N) 경계로 그대로 재사용했다. -- 카로셀 카드에 배정일 전부 표시 + 오늘/내일 카드에 chip. 마우스 오버 시 z-index 를 - 최상단으로 올려 겹친 카드가 안 가리게 했다(`PostCarousel` hover 상태). -- 달력 칸 클릭이 "카로셀로 스크롤"에서 **모달**(`Dialog`, 기존 `components/ui/dialog.tsx` - 재사용)로 바뀌었다 — 그 날짜의 글 전체 내용 + 수정·바로 발행 버튼을 그 자리에서 보여준다. -- 달력 이전/다음 달 이동을 **이번 달 ~ 1년 뒤**로 제한(`minMonth`/`maxMonth`, 문자열 - 비교로 버튼 비활성화). 그 밖의 달은 볼 이유가 없다(과거는 비어 있고, 미래는 아직 - 아무것도 배정 안 됨). - -**밟은 함정 — `scheduled_date` NULL 백필** -배포 직후 사장님이 "지금 생성하기"로 실제 만든 글 13건이 화면에서 통째로 사라져 보였다. -원인: 그 글들은 `scheduled_date` 컬럼이 생기기 *전에* 만들어져 값이 비어 있었는데, -월별·주간 조회 둘 다 이제 `scheduled_date` 로 거르는 바람에 `IS NULL` 행이 조용히 -빠졌다(SQL 에서 `NULL <= x` 는 항상 unknown). 실서버 DB 에 1회성 SQL 로 백필했다 — -업장별 `created_at` 순서를 살려 오늘부터 하루씩 순서대로 채움. 새 컬럼을 추가하는 -마이그레이션은 앞으로도 **기존 행에 값이 없을 때 조회에서 조용히 빠지는지**를 먼저 -따져야 한다. - -**검증** — `test_blog_post.py`·`test_blog_owner.py` 23 passed(`upcoming` 엔드포인트 날짜 -필터·정렬 회귀 테스트 포함). `npm run build -w @o2o/frontend` 통과. → [MINI_BLOG.md](MINI_BLOG.md) - -## 2026-09-17 — 미니 블로그 빌더 화면 — 카로셀(편집) + 달력(발행완료/실패만 표시) - -**한 일** -- `BlogPostsPage.tsx` 를 "리스트 + 달력 클릭 시 펼침" 구조에서 **카로셀(위) + 달력(아래)** - 둘로 나눴다. 카로셀(`PostCarousel`)은 이 달 글 카드를 겹쳐 쌓아 가로로 넘기는 형태고, - 편집·바로 발행 버튼은 이제 여기에만 있다. 달력(`Calendar`)은 보기 전용 — 칸마다 본문 - 앞부분 스니펫과 **발행완료/발행실패 배지만** 단다. 검수 대기·메일 발송 같은 발행 전 - 상태는 아무 배지도 안 단다. 칸을 누르면 카로셀의 해당 카드로 스크롤한다. -- `PostData` 에 `build_failed`(bool) 추가. `PostService._latest_build_failed` 가 그 - 업장의 가장 최근 BUILD 잡이 `JobStatus.DEAD` 인지 보고, APPROVED 인데 아직 안 나간 - 글에만 단다 — BUILD 잡 하나가 업장 승인분 전체를 한 번에 굽는 구조라 글 단위가 아니라 - "이 업장 재발행이 막혀 있나" 를 보는 것이다. - -**왜** -사장님 지시: "위에 겹치는 카로셀로 글들의 카드가 보이는거고 밑에는 달력에 내용앞부분 -약간이랑 발행되었는지 안되었는지 여부 이렇게 표시하면됨 발행전인건 표시하지 말고 -발행완료/발행실패 이것만 표시하면 될듯" — 앞서 만든 "오늘 게재됨/검토 대기" 요약 카드 -2장은 이 의도와 달랐다(집계 카드였지 개별 글 카로셀이 아니었다). - -**검증** — `test_blog_post.py`·`test_blog_owner.py` 22 passed(발행실패 판정 회귀 테스트 -2건 포함). `npm run build -w @o2o/frontend` 통과. → [MINI_BLOG.md](MINI_BLOG.md) - -## 2026-09-17 — 미니 블로그 빌더 화면 — 달력 + 배정일(scheduled_date) + 즉시 생성·바로 발행 - -**한 일** -- `place_posts.scheduled_date`(date) 추가(`migrations/0019_place_posts_scheduled_date.sql`, - `init.sql`, `models.py`). `(place_id, scheduled_date)` 유니크 — 업장 하나가 같은 날짜를 - 두 번 못 쓴다. 생성 시 그 업장의 `MAX(scheduled_date)` 다음날(없으면 오늘, KST)부터 하루 - 한 건씩 순서대로 배정한다(`blog_jobs._generate_for_place`). -- `PostCRUD.due_for_mail` 이 이제 `scheduled_date <= 오늘` 인 것만 고른다 — 미래 배정 글이 - 그날 되기 전에 새는 것을 막는다. `list_for_place`(빌더 화면 월별 조회)도 `created_at` 대신 - `scheduled_date` 기준으로 바꿨다. -- `BlogPostsPage.tsx` 를 리스트에서 **달력**으로 바꿨다 — 글이 0건이어도 달력 칸 자체는 - 항상 뜬다. 위에 **오늘 게재됨 · 검토 대기** 요약 카드 두 장을 살짝 겹쳐서 배치했다. -- **지금 생성하기**(`POST .../post/generate`) — 새벽 04:10 크론을 안 기다리고 그 자리에서 - 만든다. **바로 발행**(`POST .../post/{post_id}/approve`) — 안 고치고 그대로 승인. -- `SitesPage.tsx` 카드의 "더보기" 메뉴에 **디자인·컨텐츠 관리 / 미니블로그 관리 / - 예약요청 관리** 세 항목을 얹었다(탭이 아니라 메뉴 — 사장님 지시). 예약요청은 아직 화면이 - 없다 — `booking_request.py` 가 요청을 DB 에 남기지 않기로 한 결정(2026-09-16)과 부딪혀서 - 안내만 띄운다. - -**왜** -사장님 요청: "포스트들이 다 날짜가 정해져야하는데" — `created_at`(만들어진 시각)만 있고 -"언제 낼 것인가"가 없어서, 달력을 만들려면 화면이 근거 없는 날짜를 지어내야 했다. 또 -"생성된 포스트가 없어도 달력은 계속 떠야지" — 목록이 비면 화면이 통째로 빈 상태 문구로 -바뀌던 걸 고쳤다. - -**밟은 함정** — `PostCRUD.due_for_mail`/`list_for_place` 시그니처가 바뀌어(`today`/날짜 -경계 타입) 호출부를 같이 안 고치면 조용히 옛 컬럼을 봤을 것 — `_month_range` 를 -UTC datetime 경계에서 KST 순수 date 경계로 바꿔 타임존 변환 자체를 없앴다(scheduled_date 는 -timestamptz 가 아니라 DATE 라 변환이 필요 없다). - -**검증** — `test_blog_post.py`·`test_blog_owner.py` 20 passed(배정일 순서·업장당 하루 한 통 -회귀 테스트 포함). `npm run build -w @o2o/frontend` 통과(typegen·tsc·eslint·vite build). -→ [MINI_BLOG.md](MINI_BLOG.md) - -## 2026-09-17 — 미니 블로그 팀 사전검수 폐지 — 검수는 사장님이, 빌더 앱에 로그인 화면 추가 - -**한 일** -- `router/v1/site/blog_admin.py` · `services/blog_review_service.py` · `admin/frontend - BlogReviewPage` 삭제. 생성분은 금칙 필터(`is_publishable_body`)만 통과하면 곧장 - `REVIEWED` 로 쌓여 팀 개입 없이 발송 대상이 된다(`blog_service.filter_drafts`). -- `blog_jobs.py` `BATCH_SIZE`·`REFILL_BELOW` 25/40 → 30/30(한 달치). `send_reviewed()` 가 - `PostCRUD.due_for_mail`(`DISTINCT ON (place_id)`)을 써서 업장당 하루 한 통만 보낸다 — - 전엔 전체 업장을 섞어 오래된 순으로 뽑아 밀린 업장이 하루에 두 통 이상 받을 수 있었다. -- 메일 확인 화면에 **수정해서 올리기** 버튼 추가. `GET/POST /v1/site/post/edit` 신설 — - 저장하면 금칙 필터를 다시 타고, 통과하면 본문 갱신 + 그대로 승인. -- `router/v1/site/post.py` 에 `owner_router`(`/v1/place/{place_id}/post`) 신설 — 로그인 - 세션으로 이번 달 생성된 글을 보고, 메일이 아직 안 나간 `REVIEWED` 글도 바로 수정·승인. - `solution/frontend/src/pages/BlogPostsPage.tsx` + `SitesPage` 카드의 "관리" 메뉴에 - 진입점 추가. - -**왜** -2026-09-16 기획은 "팀이 먼저 거르고 사장님은 메일 클릭만" 이었는데, 다시 논의하면서 최종 -판단을 사장님에게 넘기기로 했다 — 팀 검수 단계가 병목이고, 사장님이 자기 사이트 콘텐츠를 -직접 못 보는 것도 이상했다. - -**하는 김에 잡은 버그** -`services/post_service.py` 의 승인 처리가 BUILD 잡 payload 에 `owner_user_id` 를 안 채우고 -있었다. `build_service.run_build:141` 은 `payload["owner_user_id"]` 를 무조건 읽으므로 — -**이메일 승인 클릭이 실제로는 사이트를 재발행하지 못하고 있었을 가능성이 높다**(잡은 -큐에 들어가지만 워커가 돌릴 때 KeyError). `place_id` 로 `owner_user_id` 를 직접 조회해 -채우도록 고쳤다. 회귀 테스트: `test_blog_post.py test_approve_enqueues_build_with_owner_user_id`. - -**결과** — `solution/backend` 전체 pytest 784 passed(기존에도 실패하던 `search_console` -스케줄러 잡 개수 검증 2건은 이번 변경과 무관 — `blog-drafts`·`blog-mail` 상시 잡이 늘어난 -탓, 별도 수정 필요). `tsc` 통과(solution/frontend · admin/frontend). → [MINI_BLOG.md](MINI_BLOG.md) - -## 2026-09-16 — Teams 웹훅 수신자 고장 — 플로우 재생성으로 해결 - -원인: 플로우의 `body/recipient` 가 `"48:notes"`(Teams 예약값, 실제 채팅 아님)로 박혀 있어 -`PostCardToConversation` 호출마다 BadRequest. 플로우 재생성(웹훅 템플릿) + 채널로 지정해서 -해결, 실제 채널 게시 확인함. `TEAMS_WEBHOOK_URL` 갱신함(`.env`, 커밋 안 됨). - -## 2026-09-16 — 크롤링 실패를 jobs.result 에 구조화해서 싣는다 - -`common/collect_diagnostics.py`(신규) + `collect_service.py` 채널별 실패 10곳 연결. -전엔 로그 한 줄로만 남아 원인 확인하려면 워커 로그를 grep 해야 했다 — 이제 잡 결과에도 남는다. - -**검증** — `python3 ast` 파싱, 수동 실행 확인. - -## 2026-09-16 — Gemini 호출 실패가 온보딩 생성 잡을 죽이지 않게 - -**한 일** -- `services/copy_service.py` — 소개문·FAQ 생성(`generate` 단계)에서 `GeminiError` 가 나면 - 잡을 실패시키지 않고 `generate` 를 건너뛴 것으로 기록한 뒤 fact 만으로 저장까지 계속한다. - 프론트 사유 라벨: `generationLabels.ts` `SKIP_REASONS.generation_failed`. -- `common/database/db_session_manager.py` — 유니크 제약 충돌(`IntegrityError`) 로그를 - ERROR → WARN. 재수집 시 이미 등록된 링크를 다시 넣으려는 정상 경로라 - `services/collect_service.py` `_add_link` 가 이미 "이미 있으면 그만" 으로 처리한다. - -**왜** -API 키가 아예 없을 때는 이미 `generate` 를 건너뛰고 fact 만으로 계속하면서, 키는 있는데 -**호출이 실패할 때만** 잡 전체를 DEAD 로 보내는 건 일관성이 없었다. 발행도 고유 콘텐츠 -0건으로 막지 않고(`publish_gate.check_unique_content` — "얇은 콘텐츠로 발행을 막지 않기로 -했다"), 다른 곁들이 콘텐츠(자작곡 등, `build_service.py`)도 실패하면 로그만 남기고 계속 -진행한다 — 이 갈래만 예외였다. - -실측(2026-09-15 밤, 킹서버): 사진분석(VISION) 배치가 Gemini 분당 쿼터를 다 써서, 같은 키를 -쓰는 온보딩 COPY 잡의 생성 호출도 429 를 맞고 재시도(총 20초 안팎)를 소진해 DEAD 로 갔다. -화면엔 "콘텐츠 생성을 완료하지 못했습니다" 로 떴다 — fact 만으로도 편집·발행이 되는데 -잡을 죽일 이유가 없었다. - -유니크 제약 쪽은 별개로, 이 로그가 ERROR 레벨이라 킹서버 워커 로그를 보면 크롤링이 계속 -오류나는 것처럼 보였다(실제로는 매 재수집마다 정상적으로 나는 로그). - -**남은 것** — Gemini 429 자체의 재시도 대기시간은 아직 안 늘렸다(호출 내 최대 8초 백오프 · -잡 재시도 5초/10초). 분당 쿼터가 다 찬 상황을 실제로 견디려면 더 길게 기다려야 하는데, -그만큼 워커 슬롯을 오래 묶어 두는 트레이드오프가 있어 다음 작업으로 미룬다. - -## 2026-09-15 — 장애 알림(잡 dead-letter·발행 실패·큐 정체) + /readyz - -- alert_outbox(마이그레이션 0016) + services/alert_service.py — 영구 저장 + 재시도(최대 5회, - job_crud 와 같은 백오프) + dedupe_key 로 중복 스팸 억제 + 복구 알림. 전용 컨테이너 없이 - 기존 스케줄러(API 컨테이너, 1분·5분 스윕)와 워커 코드 안 후크로 돈다. -- 알리는 지점: 잡이 DEAD 로 떨어질 때(worker/runner.py), BUILD·ROLLBACK 이 **게이트 반려가 - 아닌** 렌더·인프라 실패로 끝날 때, 노래 등 부분 실패, 잡 큐 정체(dead-letter 누적·좀비 - 실행·PENDING 정체). 게이트 반려(사장님 쪽 문제)는 알리지 않는다. -- services/teams_webhook.py — Teams Workflows 수신 webhook 어댑터(일반화, search_console_alerts.py - 와는 별도). TEAMS_WEBHOOK_URL 미설정이면 적재만 되고 전송은 안 나간다. -- detail 은 저장 전에 마스킹된다(쿼리스트링 키·Bearer 토큰·password=·이메일). -- `/readyz` 추가 — `/healthz`(프로세스 생존)와 달리 DB 에 실제로 SELECT 1 을 던져 본다. - 서버·DB 가 통째로 죽으면 이 알림 체계도 자기 장애를 못 알리므로, 외부 uptime 모니터가 - 이 경로를 봐야 한다(docs/ALERTS.md — 실제 외부 연결은 이 세션에서 하지 않았다). -- ★ 버그 하나 잡음: alert_crud.due_pending 이 파이썬에서 계산한 시각과 DB 의 next_attempt_at - 을 비교했는데, 앱·DB 서버 시계가 몇 십 ms 만 어긋나도(실측: 로컬에서 재현) send_alert - 직후 process_outbox 를 부르는 자리에서 방금 넣은 알림이 안 잡혔다. `func.now()`(DB 쪽 - 시계)로 비교하도록 고쳤다. -- 검증: tests/test_alert_service.py(신규 17건) · test_job_queue.py(dead-letter 알림 1건 추가, - 16건) · test_build_publish.py(게이트 반려/업무 실패 구분 확인 추가, 15건) · test_healthz.py - (readyz 1건 추가, 2건) 전부 통과. -- 운영 미적용: 실제 Teams webhook 생성·채널 지정, 외부 uptime 모니터 연결, 마이그레이션 - 0016 서버 적용 — 전부 사용자 승인 후 별도 진행. - -## 2026-09-15 — 운영 번들의 자동 로그인 자격증명 제거 · refresh 토큰 무효화 - -- `docker-compose.yml` `solution-site`(운영 진입점) 빌드에서 `VITE_AUTO_LOGIN_ID`·`PW` - build arg 를 없앴다 — 채워진 채로 배포하면 사장님이 여는 번들에 그대로 구워져 누구나 - JS 에서 읽을 수 있었다. `nginx/Dockerfile` 도 그 ARG 자체를 안 받는다. -- `lib/autoSession.ts` 에 `import.meta.env.DEV` 가드를 더했다(둘째 안전판) — 운영 빌드는 - 이 분기가 죽은 코드로 접혀 번들에서 통째로 빠진다. 실측: 자격증명 값을 채운 채로 - 운영 빌드를 돌려도 `build/client` 어디에도 그 문자열이 없는 것을 확인했다. -- `users.token_version`(마이그레이션 0015) 추가 — `refresh_token()` 이 지금까지 서명·만료만 - 보고 DB 를 한 번도 안 읽었다. 비밀번호를 바꿔도 이미 나간 refresh 토큰(7일)은 만료 전까지 - 계속 새 access 토큰을 찍어냈다. 이제 재발급마다 DB 의 token_version 을 대조하고, - 비밀번호 변경이 그 값을 올린다(그 전 refresh 토큰은 다음 재발급부터 거절). -- 검증: `tests/test_auth.py` 16건 통과(신규 3건 — 정상 재발급·비번 변경 후 거절·계정 차단 후 - 거절). `tests/test_schema_ddl.py` 통과(ORM ↔ init.sql 일치). -- 운영 미적용: 실제 서버 `.env` 의 `AUTO_LOGIN_ID`·`PW` 값 확인·제거와 마이그레이션 적용은 - 이 세션에서 하지 않았다 — 서버 접속·DB 변경은 사용자 승인 후 별도로 진행한다. - -## 2026-09-15 — 워커 렌더·발행 버전·예약 안내·미리보기 대기 - -- 상시 프리렌더를 제거하고 워커가 컴파일된 Node 렌더러를 실행한다. -- 버전별 HTML을 보존하고 게이트 통과 뒤 공개 링크를 전환한다. 재시도는 저장된 성공본을 사용한다. -- 예약 전 확인을 이용안내에 통합하고 iframe 렌더 완료까지 스피너를 표시한다. -- 배포는 기존 HTML과 목업을 재굽지 않는다. 상세: [PUBLISH_VERSION.md](PUBLISH_VERSION.md). -- 읽기 생성 토큰 상한을 늘리고 추첨 배열을 고정해 반복 렌더를 방지한다. -- 편집기 주소는 /builder?placeId=…로 통일한다. 옛 step=editor 주소는 ID 복원 후 정정한다. -- 검증: 사이트 81건, 백엔드 발행·롤백·서치콘솔 45건 통과. 빌더·사이트 빌드 통과. - -무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 **위에** 추가한다. -결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다. - --- -## 2026-09-14 — SNS 게재: 사장님이 누르면 글을 쓰고, 승인받아, 사장님 계정으로 올린다 - -**추가 검증 (Threads 전환 완료본)** — 격리 DB `web4ai_social_isolated_test_db`, `SCHEDULER_ENABLED=0`에서 -변경본 648 passed / 2 failed, 변경 전 HEAD 사본 635 passed / 동일한 2 failed를 확인했다. -실패는 기존 `test_rate_limit_closes_the_tap`·썸네일 호스트 기대값 검사이며 SNS 신규 13건은 모두 통과했다. -공용 테스트 DB에서는 다른 실행의 삭제/정리와 충돌했으므로 그 결과는 회귀 판정에서 제외했다. -`npm run lint`·전체 프론트 빌드 통과, site vitest 62 passed. -임시 payload를 실제 프리렌더해 데스크톱·모바일 하단 카드를 확인했고, SNS 글만 있는 payload는 -고유 콘텐츠 0건으로 발행 거부됨을 확인했다. 실제 Threads 게시·알림톡 발송·운영 배포는 실행하지 않았다. -운영 활성화 전제와 남은 정책은 [SOCIAL.md](SOCIAL.md)에 정리했다. - - -**무슨 일** — 발행한 사이트로 사람을 데려올 경로가 제품 안에 없었다. IndexNow 통보와 사이트맵뿐이고, -그건 검색엔진이 언제 읽을지 우리가 모른다. 이제 사장님이 [SNS에 알리기] 를 누르면 확인된 fact 로 -짧은 글을 쓰고, 승인을 받아 **사장님 개인 계정**(스레드)으로 올린다. 올린 글은 발행본 맨 아래에도 실린다. - -**★ 이 변경의 크기** — 섹션 하나 추가가 아니다. 이 레포가 처음으로 ①외부에 **쓰기**를 하고 -②**남의 계정 자격증명을 보관**하고 ③**되돌릴 수 없는 행위**를 한다. 아래 결정이 전부 여기서 나왔다. - -**승인을 다시 둔다 — 7절의 예외** ([DECISIONS 7-1절](DECISIONS.md)) -7절("LLM 이 쓴 문장은 승인 없이 나간다")의 "왜 안전한가" 두 줄이 여기서는 둘 다 성립하지 않는다. -기준은 문장의 참/거짓이 아니라 **명의**(사장님 계정의 발언) · **되돌릴 수 있나**(없다) · -**무엇이 주로 틀리나**(문장이 아니라 링크 — `_publish_target` 이 계산하므로 앞 게이트가 못 본다)다. -7절의 함정은 구조로 막았다: 시작이 사장님 클릭이라 "안 눌러서 영영 안 나감" 이 생기지 않고, -승인 경로가 둘(알림톡·빌더)이며, 미승인은 만료되어 **화면에 보이게** 남는다. - -**★ 게시는 주소가 확정된 사이트에만.** `sites.domain` 이 비면 발행 슬러그가 **상호명에서 파생**되고 -(`_publish_target`), 상호를 고치면 주소가 통째로 바뀐다 — `SITE_SLUG_LOCKED` 는 `domain` 변경만 -막으므로 여기엔 안 걸린다. 이미 올라간 글의 링크는 404 가 되고 **그 글은 수정할 수 없다.** -→ `PUBLISHED` + `current_version_id` + `domain` 셋이 다 있을 때만 허용한다. - -**★ 승인은 GET 이 아니라 POST.** 메신저 링크 미리보기·백신·프리페치가 사람이 누르기 전에 URL 을 -연다. GET 승인이면 사장님이 안 눌렀는데 올라가고 로그에는 "승인됨" 으로 남는다. -일회성은 토큰이 아니라 `status='PENDING_APPROVAL'` 조건이 붙은 단일 UPDATE 가 보장한다. - -**게시는 기본으로 꺼져 있다**(`SOCIAL_POSTING_ENABLED=0`). 초안·승인까지는 계약 없이 돌지만 -게시는 되돌릴 수 없어서, 플랫폼 계약과 **1-4(해지 시 사이트 처리)** 결론을 확인한 뒤 사람이 연다. -★ 1-4 가 이 기능의 **전제조건**이 됐다 — 외부에 영구 링크를 뿌리는 순간 "유예 기간 미정" 이 -"죽은 링크 정책 미정" 이 된다. - -**사진은 올리지 않는다.** 1-2(이미지 재게시)의 격리는 "나중에 필터로 뺄 수 있다" 는 전제 위에 있는데 -SNS 는 그 전제가 깨진다(플랫폼 서버에 사본이 생긴다). 게다가 지금 OWNER 사진은 존재할 수 없다(5-3). -→ 필터가 아니라 **미디어 첨부 코드를 아예 만들지 않았다.** - -**플랫폼은 스레드다.** X 는 URL 이 든 글을 쓰는 데 **요청당 $0.20** 이 안내돼 있어(공식 가격표), -"계정 단위 고정비" 라는 처음 가정이 틀렸다 — 사이트마다 나가는 변동비다. 스레드는 직접 API 에 -건당 과금 안내가 없다. 어댑터 경계는 그대로 두되 X 어댑터는 넣지 않았다([API_USAGE 5절](API_USAGE.md)). - -**밟은 함정 둘** -- **ORM 기본값에 쉼표가 딸려 들어갔다.** `server_default=text("'[]',")` → `DEFAULT '[]', NOT NULL` - 로 나가 **CREATE TABLE 이 통째로 실패**했다. 운영 DB 는 init.sql 로 만들어져 안 드러나고 - **ORM 이 스키마를 만드는 테스트 DB 에서만** 터진다 — 9월 10일의 `now()` 기본값 사고와 같은 자리다. -- **승인 스윕 주기가 1분이었다.** 쓰기 커넥션을 계속 집어 들어, 같은 컨테이너에서 도는 테스트가 - 커넥션을 못 받아 `TimeoutError` 로 무더기 실패했다(실측). 이 스윕이 하는 일은 "만료 표시" 와 - "중단된 초안 정리" 뿐이라 분 단위 정밀도가 필요 없다 → **5분**. - -**검증** — 백엔드 SNS 테스트 9건 통과(초안 dedup·owner 스코프 · 주소 고정 요구 · GET 프리페치가 -상태를 안 바꾸는지 · 승인 CAS 일회성 · 만료·중단 스윕). `tsc -b`·`eslint` 통과(shared·site·frontend), -vitest 58 passed(신규 3). 스케줄러를 끈 상태에서 snapshot·vision·social 26건 동시 통과. - ---- - -## 2026-09-15 — Google 사이트맵 자동 제출·색인 관측 - -- 기존 스케줄러에서 발행 완료 DB 감지 → 사이트맵 제출 → 색인 조회 → 지연/실패 알림. -- 관측값·재시도·알림 시각은 `site_search_status`에 보관. 발행 잡/상태는 건드리지 않는다. -- API 인증/호출과 DB·배치·알림 모듈 분리. Google·Teams 실호출은 설정 전까지 꺼진다. -- 설정/적용/관측 의미: [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md). 운영 배포·권한 부여는 미실행. - -**검증** — 관련 59건 통과. 추가 회귀 23건 통과·기존 발행 검수 실패 1건(변경 전 코드에서도 재현). - -## 2026-09-15 — 콘텐츠 생성 진행 상태·새로고침 복구 - -- COPY의 실제 단계 상태를 DB에 기록하고 Orval 응답으로 표시. 폴링 횟수 기반 진행률 제거. -- URL의 jobId로 조회 재개. 구 URL 복구는 완료·실패 이력까지 재사용해 중복 생성 방지. -- 실행 흐름·단계 메소드·프롬프트·프론트 조회 훅·화면 문구 분리. -- 구조·적용 순서: [GENERATION_FLOW.md](GENERATION_FLOW.md). - -**검증** — 백엔드 관련 테스트 34건·브라우저 복구/실패 시나리오 6건 통과. 프론트 타입검사·lint·빌드 통과. - ---- - -## 2026-09-14 — 엽서 쓰기를 발행본에도 넣는다 (사진이 남의 도메인이면 저장·공유는 막힌다) - -**무슨 일** — 시연본에만 주입 스크립트로 있던 '엽서 쓰기'(사진 고르기 + 한 마디 + 캔버스 엽서)를 -발행본 컴포넌트로 옮겼다. 그리기 규칙은 `site/src/lib/postcard-canvas.ts` 한 곳에 두고, -화면·입력·공유는 `sections/items/PostcardMakerSection.tsx` 가 맡는다. 사진이 있는 사이트면 나간다. - -**★ 저장·공유가 사진 출처에 걸린다** — 캔버스는 **남의 도메인 사진을 그리면 오염돼서**(tainted) -`toBlob` 이 SecurityError 로 막힌다. 미리보기는 멀쩡히 보이는데 저장·공유만 죽는, 눈으로는 못 찾는 종류다. -CORS 로 받으면 안 오염되지만 실측(2026-09-14) 발행본 사진은 네이버 CDN(`*.pstatic.net`)에 있고 -그쪽은 `Access-Control-Allow-Origin` 을 주지 않는다 — `curl -I` 로 확인했다. - -→ 지금은 **정직하게 막는다.** CORS 로 한 번 받아 보고, 실패하면 CORS 없이 다시 받아 미리보기만 세우고 - 저장·공유 단추를 아예 감춘다("이 사진은 다른 사이트에 올라와 있어 …"). 눌러도 안 되는 단추를 두지 않는다. -→ **근본 해결은 사진을 우리 오리진으로 옮기는 것이다.** 시연본이 `img/mirror/` 로 그렇게 하고 있고, - 발행 파이프라인이 같은 일을 하면(빌드 때 내려받아 `out/s//img/` 에 두고 payload 주소를 바꾼다) - 저장·공유가 풀린다. 덤으로 외부 주소 만료·핫링크 문제도 같이 사라진다. **아직 안 했다.** - ---- - -## 2026-09-14 — FAQ 를 20개까지 채운다 (펜션 공통 질문 30개 + 문의 안내) - -**무슨 일** — COPY 잡의 FAQ 생성 상한을 8 → 20 으로 올리고, 그래도 모자라면 펜션 공통 질문 카탈로그에서 -겹치지 않는 질문을 골라 **문의 안내** 답으로 채운다. -``` -생성(fact 근거, 최대 20) → 노출 중 FAQ 세기(생성분 + 사장님 입력·정정분) - → 모자란 만큼 카탈로그 순서대로: fact 로 답할 수 있는 질문 · 이미 다룬 주제(근거 key / 질문 키워드) 건너뜀 - → "…은 전화(…)로 문의해 주시면 안내해 드립니다" (generated_by=TEMPLATE, VERIFIED) -``` - -**왜** — 확인된 fact 로만 쓰면 4~8개에서 끝났다(실측 로컬: 스테이머뭄 fact 8건, 산하연 풀빌라 fact 4건 · FAQ 4건). - -**★ 공통 답에 값을 적지 않는다** — 가게마다 다른 값(바비큐 가능·반려동물 불가·체크인 15시)을 공통으로 적으면 -업종 시드 FAQ 가 가공의 가격을 내보낸 사고와 같다. 답은 문의 안내뿐이고, 그래서 **화면에만** 나간다 — -FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수(prerender ↔ conftest) · SEO 감사 FAQ 점수에서는 뺐다. - -**바꾼 곳** -- `common/faq_catalog/`(신규): 카탈로그 로더 + `resources/pension.json`. fact_keys 가 업종 스키마에 없으면 로드 시 예외. -- `services/faq_fill.py`(신규): 고르기 규칙(순수 함수). `copy_service._fill_faqs` 가 부른다. -- `SourceType.TEMPLATE = 5`(백엔드 enum · shared · orval 모델). fact 에는 못 쓴다(`fact_service` 규칙 4). -- `postgres-init/migrations/0012_place_faqs_template_source.sql` + `init.sql`: 컬럼 변경은 없다(CHECK 없는 SMALLINT). - `generated_by` · `source_fact_ids` 에 코드값 뜻을 `COMMENT ON` 으로 남긴다. 0012 는 컬럼이 있을 때만 단다(`DO $$ IF EXISTS`). - init.sql 은 옛 주석("비면 발행 게이트가 반려한다" — 그런 검사는 없었다)을 고치고 같은 `COMMENT ON` 을 붙였다. -- `faq_crud.expire_generated`: TEMPLATE 도 재생성 때 내린다 — 안 내리면 새 fact 로 답이 생긴 주제에 옛 문의 안내가 남는다. -- 프롬프트: fact 로 답할 수 있는 카탈로그 질문을 싣고, "한 문항에 주제 하나" 규칙 추가 - (노출 중 생성 FAQ 4건 중 3건이 "체크인 및 체크아웃" 식으로 묶여 있었다). -- ★ fact 0건이어도 20개: `start_copy` 는 카탈로그가 있으면 잡을 만들고(`FAQ_UNGROUNDED` 는 카탈로그 없는 업종만), - `run_copy` 는 근거가 없거나 키가 없으면 LLM 없이 채우기만 한다. 온보딩 알림(`notifyCopy`)도 `faq_fill` 을 본다. -- 발행본 FAQ 섹션: 문의 안내가 섞이면 "모두 사업자가 확인한 내용" 안내 문구를 달지 않는다. -- 빌더 FAQ 패널: "노출 N건 (문의 안내 M)" 과 문의 안내 표시. - -**남은 것** — 카페·음식점·체험시설 카탈로그. 스키마에 없는 주제(짐 보관·퇴실 정리·보증금·수영장 온수·주변 편의시설)는 -fact key 로 만들면 문의 안내 대신 답이 된다. 결론은 [DECISIONS 8절](DECISIONS.md). - -**검증** — 백엔드 664 passed(신규 `test_faq_fill` 10건 · `test_copy_api` 3건, 기존 2건은 fact 0건 경로에 맞게 고침). -실패 2건(`test_place_search::test_rate_limit_closes_the_tap` · `test_site_thumbnail` 호스트)은 이 변경 전 HEAD 에서도 같게 실패한다. -site·frontend·admin `tsc --noEmit` 통과 · site vitest 63 passed. -로컬 실사업장(2026-09-14, 하늘물빛정원 — fact 4건): 생성 FAQ 4건 + 문의 안내 16건 = 20건, 질문 중복 0. -0012 는 새 DB(init.sql → migrate 규칙)와 로컬 DB 사본 양쪽에서 두 번씩 적용해 통과. - ---- - -## 2026-09-14 — 발행 사이트 제목·keywords 메타에 SiteOntology 키워드를 싣는다 - -**무슨 일** — 숙박 사이트를 빌드할 때 SiteOntology(o2o-site-ontology)에 이 가게 프로필을 보내 검색 키워드를 -받고, 거른 결과를 `` 와 제목 업종어 자리에 싣는다. -``` -스냅샷 → 프로필(확인된 fact · 주소 · 발행되는 주변 관광지) - → POST /v1/merchants/publish (generate:false) → POST /v1/match (query=place_id) - → 거르기 → snapshot["seo"] → payload.seo - → 스테이,머뭄 · 군산 독채펜션 · -``` - -**★ 거르기가 필요한 이유 (실측)** — 스테이머뭄 프로필로 받은 추천 10건 중 `군산 독채 마당 펜션`· -`군산 독채 복층 펜션`·`군산 커플 프라이빗 펜션` 이 status=ok 로 왔다. SiteOntology 의 사실 필터는 수용 인원과 -일부 시설만 보기 때문이다. 사전에는 `선유도 독채펜션`(다른 권역)·`군산 펜션 최저가`(가격 주장)도 있다. -→ **키워드의 모든 낱말이 이 가게 자료에 있어야** 싣는다. 이 규칙 하나로 셋이 같이 걸리고, 10건이 4건이 됐다. - 제목에는 `예약`·`추천` 이 붙은 것과 시·군 이름이 없는 것도 뺀다. 규칙의 단일 출처는 `services/seo_keywords.py`. - -**★ SiteOntology 쪽 함정 (실측)** -- region 표에 없는 `regionId` 를 보내면 **500**(외래키 위반). 표 내용은 적재한 데이터셋에 따라 달라 우리가 모른다 - → 500 이면 지역 없이 한 번 더 보낸다. -- 해석되지 않은 `query` 에도 **201** 로 입력 문자열 검색 결과를 준다(`나운동 숙소` …) → `resolved` 가 - 우리 place_id 가 아니면 버린다. - -**경계** — SiteOntology 는 **수정하지 않았다**. 설정(`SITE_ONTOLOGY_URL`)이 비면 호출하지 않고, 실패하면 -키워드 없이 예전 제목으로 발행한다. 키워드는 스냅샷에 실려 `site_versions.snapshot` 이 곧 발행 기록이다. - -**남은 것** — SiteOntology 매칭은 지역으로 거르지 않는다. 지금은 낱말 대조가 다른 지역 단어를 막지만, -운영에 전국 데이터가 들어가면 SiteOntology 에 지역 필터를 넣는 것이 맞다. - ---- - -## 2026-09-11 — 발행하면 이 숙소의 노래가 한 곡 생긴다 (가사 Gemini → 작곡 Suno) - -**무슨 일** — `/s/stay` 시안에는 헤더에 노래 플레이어가 있는데, 그건 손으로 채운 목업이라 -새로 발행한 사이트에는 그 자리가 아예 없었다. 이제 발행이 노래를 만든다. - -**흐름** — ★ **발행이 노래를 기다린다.** -``` -발행 누름 → BUILD 잡 - 1. 가사(Gemini) → 2. 작곡(Suno, 실측 30~40초 · 상한 5분) - 3. mp3 를 out/songs/ 에 보관 - 4. 스냅샷 → 게이트 → 발행 ← 여기서 비로소 사이트가 나간다 - 프리렌더가 mp3 를 사이트 디렉토리로 복사 -``` - -**왜 기다리나** — 먼저 굽고 나중에 붙이는 방식으로 먼저 만들어 봤는데, 그러면 발행 직후의 -사이트에는 노래가 없고 몇 분 뒤 조용히 생긴다. 사장님이 [사이트 열기] 로 보는 **첫 화면에 -그 기능이 빠져 있다.** 값은 발행이 그만큼 늦어지는 것이고, 그건 감수한다. -★ 단 실패는 발행을 막지 않는다 — 기다리는 것과 막는 것은 다르다. 키가 없거나 작곡이 실패하면 -노래 없이 발행되고 사유가 빌드 로그와 `place_songs.last_error` 에 남는다. -★ 미리보기 빌드(publish=false)에는 만들지 않는다. 유료 호출이라 눌러 보는 것만으로 돈이 나가면 안 된다. - -**왜 가사를 우리가 쓰나** — Suno 에 "군산 한옥 숙소 노래" 라고만 던지면 가사를 저쪽이 짓는다. -그 가사에는 이 숙소에 없는 것(수영장·조식·오션뷰)이 섞이고 우리는 검증할 방법이 없다 — -사이트의 다른 모든 문장은 확인된 fact 로만 쓰는데 노래만 지어낸 말을 싣는 꼴이다. -→ 가사는 **소개문과 같은 재료**(확인된 fact + 조사 근거 + 소개문)로 Gemini 가 쓰고, - Suno 는 곡만 붙인다. 프롬프트가 **없는 시설·숫자를 말하지 말라**고 못 박는다 - (요금·전화번호를 노래에 넣으면 틀렸을 때 고쳐 부를 수가 없다). -★ 가사에는 `ground_check` 를 걸지 않는다. "밤이 깊어도 불이 켜져 있다" 에 대응하는 fact 는 - 없다 — 문장 단위로 근거를 맞추면 전부 반려된다. 가사는 사실 진술이 아니라 정서다. - -**★ Suno 주소를 그대로 싣지 않는다** -Suno 가 주는 audio_url 은 **만료된다.** payload 에 그 주소를 실으면 발행 직후에는 재생되고 -몇 주 뒤 조용히 죽는다 — 아무도 안 누르면 죽은 줄도 모르는 종류다. mp3 를 받아 보관하고 -우리 경로(`/s//.mp3`)만 발행본에 내보낸다. - -**★ 콜백이 아니라 폴링이다** -Suno 는 `callBackUrl` 로 완료를 알려 주는데, 그러려면 Suno 가 우리 백엔드에 닿아야 한다. -이 서버는 로컬(:9800)이거나 사내망이라 그런 주소가 없다 — 콜백을 믿게 만들어 두면 -"요청은 성공했는데 결과가 영영 안 옴" 이 되고, 화면상 아무 일도 안 일어나는 실패다. -(API 가 필수로 요구해서 값은 채워 보내되, 그 주소를 듣지 않는다.) - -**경계는 그대로다** — 백엔드는 여전히 발행물 디렉토리를 모른다. payload 와 같은 약속으로 -`out/songs/.mp3` 에 떨구고, 굽는 쪽인 프리렌더가 `out/s//` 로 복사한다. -프리렌더는 복사하면서 **지난 발행의 mp3 를 치운다** — 발행마다 새 곡이라 안 치우면 1MB 짜리가 -발행 횟수만큼 쌓이고, Azure 에도 그대로 올라간다. - -**화면** — 헤더의 작은 플레이어(`SongPlayer`). 곡이 없으면 **아무것도 그리지 않는다** — -노래는 발행보다 늦게 도착하므로 그 사이 빈 플레이어를 그리면 고장난 버튼이다. -자동 재생하지 않고(소리가 갑자기 나는 페이지는 닫힌다), 가사를 함께 싣는다 -(오디오 안의 말은 크롤러가 못 듣는다). - -**표** — `place_songs`. 검증 상태가 없다(창작물이라 "맞는가" 를 물을 대상이 아니다). -상태는 `GENERATING`·`READY`·`FAILED` 셋이고 스냅샷은 READY 만 싣는다. 새 곡이 실패하면 -직전 곡이 그대로 남는다. → [DATA_MODEL.md](DATA_MODEL.md) - -**검증** — 실제로 발행해 봤다(스테이,머뭄 v15): 가사 '시간이 머무는 고요한 밤'(acoustic -ballad, 154자, $0.0014) → 작곡 40초 → 1.98MB mp3 → **그 다음** 스냅샷(노래 1) → 발행 완료. -`/s/스테이머뭄-99a887f8` 200, mp3 200 `audio/mpeg`, HTML 에 제목·가사·재생 주소 확인. -지난 발행의 곡은 404 로 치워졌다. `tsc --noEmit` · `eslint` · vitest 55건 통과(신규 4건). - ---- - -## 2026-09-10 — 소개문이 생성되고도 영영 안 나가던 것 (승인 단계 제거) - -**무슨 일** — 힐튼 가든 인 서울 강남을 만들어 보니 소개가 빈칸이었다. 로그는 `[copy] 소개문 O`, -DB 에도 문장이 있는데 `status=1(UNVERIFIED)` 이라 스냅샷이 담지 않았다. 그 자리는 fact 로 조립한 -한 줄("서초구에 있는 …입니다. 체크인 15:00.")이 대신 채우고 있어서, 화면만 보면 생성이 실패한 -것처럼 보이지도 않았다. - -**왜 승인이 안 됐나 — 승인할 화면이 없었다.** -``` -07:29:04 수집 완료 → 여기서 사장님이 [맞아요] 를 눌러 fact 가 VERIFIED 가 된다 -07:31:11 ★ 소개문 도착 — 2분 늦게. 확인 화면은 이미 지나갔다 -``` - -**한 일** -- `fact_service.upsert_fact`: **LLM 출처는 후보가 아니라 노출값으로 앉힌다.** 자동 출처(API·CRAWL)는 - 그대로 후보다. 게이트는 앞에 있다 — 입력이 확인된 fact 뿐이라 이미 승인된 사실로 쓴 문장이다. -- `copy_service`: 생성 FAQ 를 `VERIFIED` 로 저장한다. 근거 없는 FAQ 는 여전히 저장하지 않는다. -- **잠금은 명시적으로 다시 걸었다.** 사장님이 고친 문장(`CORRECTED`)은 LLM 이 못 덮는다. - 지금까지 이 보호는 "자동 출처는 노출값 경로로 못 간다" 는 **경로**가 대신 해 주고 있었다 — - LLM 만 경로를 바꾸면 그 보호가 조용히 사라진다(절대규칙 6). -- `faq_crud.expire_generated`: 재생성 대상을 status 가 아니라 `generated_by` 로 가른다. - 생성분이 VERIFIED 로 들어가면 status 로는 사람이 손댔는지 알 수 없다. 그대로 뒀다면 재생성이 - 옛 FAQ 를 못 내려 같은 질문이 쌓였을 것이다. -- 결론과 근거는 [DECISIONS.md 7절](DECISIONS.md). 6-2 의 "FAQ 에는 넓히지 않는다" 도 함께 고쳤다. - -**곁다리로 잡은 것 — 테스트가 통째로 막혀 있던 진짜 이유** -ORM 의 TIMESTAMPTZ 기본값이 `(now() AT TIME ZONE 'utc')` 였다. timestamptz 에 이걸 쓰면 값이 -시간대 없는 벽시계로 떨어졌다가 세션 시간대로 다시 해석돼 **서버 시간대만큼 미래로 밀린다.** -실측: 잡의 `run_after` 가 7시간 뒤로 박혀 `claim`(`run_after <= now()`)에 영영 안 걸렸고, -COPY 관련 테스트가 "잡이 PENDING 인 채" 무더기로 실패했다. 원인이 코드가 아니라 스키마라 -읽히지 않는 종류다. 운영은 멀쩡했다 — 운영 DB 는 `init.sql`(`DEFAULT now()`)로 만들어지고 -이 기본값은 **ORM 이 스키마를 만들 때만**, 즉 테스트 DB 에서만 쓰인다. -→ `init.sql` 과 같은 `now()` 로 맞췄다. 스키마는 init.sql 이 단일 출처다. - -**검증** — fact·copy·faq 35건 통과(신규 2건: LLM 문장이 승인 없이 노출값이 되는지 · -CORRECTED 를 못 덮는지). - ---- - -## 2026-09-10 — 옛 항구 템플릿을 `/s/stay` 시안에 맞춘다 (렌더러 이식) - -**무슨 일** — 옛 항구를 골라도 시안처럼 안 나왔다. 시안의 출처를 따라가니 이 레포가 아니라 -**`stay-mockup` 워크트리의 커밋되지 않은 작업본**이었다(19파일, +601/−275). 거기서만 살아 있던 -변경이 이 브랜치로 넘어오지 않아, 같은 payload 를 같은 템플릿으로 구워도 화면이 갈렸다. - -**대조 방법** — 시안 HTML 에 박힌 `window.__SITE_PAYLOAD__` 를 떼어 **현재 렌더러로 다시 구워** -마크업을 태그 단위로 diff 했다. 페이로드가 같으니 남는 차이는 전부 렌더러 차이다. -착수 시 실질 diff 129줄 → 이식 뒤 **4줄**. - -**옮긴 것** -- `lib/ui/Carousel.tsx` + `use-rail-autoplay.ts`(신규): 자동 넘김을 훅 한 벌로. **한 번 훑고 멈춘다** — - 되감기(`loop`)를 빼야 embla 가 슬라이드를 개별 transform 으로 옮기지 않아 이음매 간격이 안 붙는다 -- `FestivalSection`: 격자 → **계절별 캐러셀 4개**(봄·여름·가을·겨울) -- `ItinerarySection` + `items/common.tsx`: 코스마다 레일을 쌓던 것을 **탭 하나 = 레일 하나**로. - 실측 payload 에서 캐러셀 20개 → 2개(1박2일·2박3일) -- `lib/format.ts`: 지도 주소에서 **쉼표를 뺀다.** 카카오 `link/to/{이름},{위도},{경도}` 는 쉼표로 칸을 - 가르는데 상호가 "스테이,머뭄" 이면 위도 자리에서 "머뭄" 을 읽고 **목적지를 통째로 버린다** — - 길찾기가 현위치만 뜨던 원인 -- `seo/verify.ts`: JSON-LD 이미지가 절대 URL, HTML 은 루트 절대경로(`/assets/…`)라 **경로로도 대조**한다. - 이게 없어서 사진을 미러한 사이트는 발행 게이트가 통째로 막혔다(시안 payload 재굽기가 9건으로 실패) -- 그 밖에 `GallerySection`(간격) · `VideoSection` · `UnitsTabs` · `UnitsBands` · `LocalGuideSection`(레일 간격) - · `WeatherSection` + `WeatherBand`/`tempNotes`(기온대별 한 줄) · `SiteHeader`(safe-t) · `seo/jsonld`·`head` - -**이 브랜치 것을 지킨 자리** — 충돌 6곳은 손으로 갈랐다. -- `ItinerarySection`: 사장님 일정이 없으면 **서버 조립분**(`local.itineraries`)을 쓰는 폴백을 유지 -- `seo/verify.ts`: 이 브랜치의 `unescaped` 대조와 시안의 경로 대조를 **둘 다** 본다 -- 예약 버튼 문구는 시안(`{채널}로 예약`)이 아니라 이 브랜치의 `bookingActionLabel` 을 남겼다 — - 네이버 예약 채널에서 "네이버 예약로 예약" 이 되는 것을 막는 쪽이 맞다. **남은 diff 4줄이 이것이다** - -**템플릿 쪽** — `TemplateItem` 에 `defaultVariants` 를 더하고 옛 항구에 `photos: 'photos.carousel'` 을 건다. -시안의 사진 갤러리가 캐러셀인데 템플릿이 배리에이션을 지정할 자리가 없어 늘 기본으로 나갔다. -`disabledSectionTypes`(끄고 시작할 섹션) 기구도 함께 두되 **옛 항구에는 쓰지 않는다** — 예약 안내는 나간다. - -**검증** — `tsc`(shared·site·frontend·admin) · eslint 통과. 시안 payload 를 현재 렌더러로 프리렌더 → -**검증 게이트 통과**, 캐러셀 11개가 시안과 같은 구성·순서. site vitest 는 7 failed / 44 passed 로 -**착수 전과 같다**(stay-booking 7건은 이 작업 이전부터 실패). - -⚠️ `/s/stay` 는 건드리지 않았다. 다만 `solution/site/payloads/stay.json` 이 남아 있는 한 -**프리렌더 컨테이너가 기동할 때마다 목업이 그 payload 로 덮인다**(`watch-payloads.mjs` 의 `기동` 전체 재굽기). -목업은 payload 가 없어야 안전하다 — stay2·stay3 가 무사한 이유가 그것이다. - -## 2026-09-10 — 일력(오늘의 한 장)을 서버 생성에 붙인다 · 종류가 늘어도 기존 지역이 따라온다 - -**무슨 일** — '옛 항구' 템플릿을 골라도 `/s/stay` 시안처럼 안 되는 자리를 따라갔더니 하나가 -코드 문제였다. **일력만 서버가 만들지 않는다.** 렌더러에는 '오늘의 한 장' 탭이 있고 -(`StorySection` 다섯 탭 중 둘째) 템플릿 설명도 "도넛판·**일력**·승차권"이라고 약속하는데, -프롬프트가 빌더(`canvas/dataSpec.ts`)에만 손으로 적혀 있어 `shared/section-prompts.ts` 에 -없었다 — 서버는 그 종류가 있는 줄도 몰랐다. 시안에 일력이 있는 건 그때 손으로 넣었기 때문이다. - -**같이 나온 두 번째 함정** — 목록이 두 벌이었다. `export-prompts.mjs` 가 종류 배열을 -손으로 한 벌 더 들고 있어서, `STORY_KINDS` 에 하나를 늘려도 **뽑히지 않는다**. -프론트는 아는데 서버만 모르는 상태가 되고, 그 종류의 탭은 조용히 빈칸으로 남는다. - -**세 번째 — 가드가 정확히 반대로 돈다** — `has_stories()` 는 "한 건이라도 있으면 다시 안 부른다" -였다. "같은 지역 두 번째 숙소"만 생각한 가드라, **종류가 늘어난 날** 이미 다섯이 든 지역 -(52군산시)은 여섯 번째를 영영 못 받는다. 새 지역만 여섯이 되고 기존 지역은 다섯에 멈춰, -같은 템플릿을 골라도 지역에 따라 탭 수가 다른 상태가 된다. - -- `shared/section-prompts.ts`: `daily` 스펙 추가(maxItems 30) · `STORY_KINDS` 를 발행본 탭 순서로 -- `shared/scripts/export-prompts.mjs`: 종류 목록을 손으로 적지 않고 `STORY_KINDS` 에서 읽는다 -- `frontend/canvas/dataSpec.ts`: 일력의 task·rules 를 shared 참조로 — 다섯과 같은 모양이 됐다 -- `backend/story_service.py`: `has_stories` → `missing_kinds` — **없는 종류만** 부른다. - 요금 가드는 그대로다(있는 종류는 여전히 한 번도 다시 안 부른다). 읽기 실패는 "없다"로 - 치지 않는다 — 모르는 상태로 유료 호출을 걸지 않는다 -- `backend/enums.py` · `grounding/story.py` · `init-data/init.sql`: 여섯으로 맞춤 - -**검증** — `tsc --noEmit`(shared·frontend·site) · eslint 통과. 프롬프트 계약 테스트 2건 추가. -실제 payload(`stttt`)의 `local.story.daily` 에 두 건을 넣고 구워, '오늘의 한 장' 탭이 -다섯 번째로 서는 것까지 확인했다. -⚠️ pytest 전체는 이 브랜치 이전부터 로컬 Postgres 인증 실패로 막혀 있다 — 새 테스트는 DB 를 -안 쓰지만 세션 픽스처가 먼저 걸린다. 개별 함수를 직접 호출해 통과를 확인했다. - -**아직 남은 것(코드가 아니라 데이터)** — `/s/stay-mumum-gunsan` 이 시안과 다른 나머지는 -소개·객실·FAQ·영상·소식과 fact 8건이 비어서다. 사장님이 채우거나 수집이 가져와야 한다. - -## 2026-09-09 — 지역 이야기를 서버가 채운다 (가요·인물·연표·엽서·퀴즈) - -**무슨 일** — 이 다섯은 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다. -`/s/stay` 시안이 다섯을 다 갖고 있는 건 그때 손으로 채웠기 때문이고, 새 업장은 옛 항구 -템플릿을 골라도 그 자리가 비었다. 이제 지역 단위로 한 번 생성해 같은 지역 사이트가 나눠 쓴다. - -- **키는 지역이다.** `area_contents`(region_code × kind) 에 종류당 한 행, `body.items` 에 항목들. - 사이트별 `sections[].data` 로 복사하지 않는다 — 화면이 읽는 순간에만 사장님이 붙여넣은 것과 - 한 배열로 잇는다(`site/src/lib/derive.ts` `sectionItems`). -- **Perplexity 종류당 1회.** 출처(`search_results`)가 함께 오는 유일한 통로다. 항목에 출처가 - 없으면 버리고, 검색 출처로 때운 항목은 `확인` 이라 우겨도 `확인필요` 로 내린다. -- **프롬프트는 한 벌.** 사장님이 [콘텐츠] 탭에서 복사해 가던 그 문장을 그대로 쓴다 — - `shared/lib/section-prompts.ts` 가 단일 출처, `npm run export:prompts` 로 백엔드용 JSON 을 뽑는다. -- **트리거는 cache-aside.** 에디터 캔버스가 주변 정보를 처음 부를 때 지역 이야기 생성 잡 - (`JobType.LOCAL_SYNC`, 선언만 있고 미배선이던 것)을 하나 넣는다. `dedupe_key = story:{region_code}` - 라 같은 지역 숙소 50곳이 동시에 열어도 잡은 하나다. -- 검수 게이트는 두지 않는다 — 결론과 근거는 [DECISIONS.md 6절](DECISIONS.md). - -**검증** — `tsc -b` 통과 · 지역 이야기 단위 테스트 12건 통과. -⚠️ 이 레포의 pytest 전체는 이 브랜치 이전부터 **로컬 Postgres 인증 실패로 569건 전부 error** 다 -(`password authentication failed for user "postgres"`). 새 테스트는 DB 를 안 쓰는데 세션 픽스처가 -DB 를 먼저 세워서 함께 막힌다 — 환경 문제이고 별건이다. - -## 2026-09-09 — 예약 안내 안에 날짜·시간 목업을 넣는다 (연동 없음) - -**무슨 일** — 예약 흐름을 화면으로 보기 위해 `StayBookingDemo` 를 예약 안내 섹션 안에 넣었다. -날짜(2주) · 도착 시간 · 객실 · 인원을 고르면 확인 화면이 나오고, 거기서 전화로 잇는다. -**어디에도 연동하지 않는다** — 재고 조회도 접수도 결제도 없다(PRODUCT.md 6절은 그대로다). - -**목업이라도 지킨 선** -- **"마감/잔여" 를 만들지 않는다.** 우리는 그 값을 모른다. 그럴듯하게 지어내면 목업이 아니라 - 거짓말이고, 손님은 그 표시를 보고 다른 날을 고른다 -- **시간 후보를 임의로 늘어놓지 않는다.** 체크인 fact(16:00)에서 시작해 5칸을 만든다 — - fact 가 없으면 시간 선택을 아예 내지 않는다. 확인된 값과 어긋나는 선택지는 만들지 않는다 -- **요금은 요금표·JSON-LD 와 같은 출처**(`unitBaseRate`)를 쓴다. 데모라고 다른 숫자를 보이면 - 같은 페이지가 두 값을 말하게 된다 -- 확인 화면은 "접수됐다" 고 쓰지 않는다 — 어디에도 보내지 않으므로 사실이 아니다. - 반대로 "접수되지 않았다" 는 경고도 두지 않는다(2026-09-09 결정: 흐름을 보는 화면이라 - 경고문이 흐름을 가린다). **선택 내용 확인**까지만 말하고 전화로 잇는다 - -**★ 날짜는 브라우저에서 만든다 (mounted 게이트)** -프리렌더가 서버에서 날짜를 구우면 **발행 시각의 날짜가 정적 HTML 에 박힌다.** 한 달 뒤 -크롤러가 그 페이지를 읽으면 지난 날짜가 예약 가능일로 적혀 있다 — 화면은 멀쩡한데 기계가 -읽는 값만 틀리는, 이 레포가 가장 자주 밟은 종류다. 그래서 서버 렌더에서는 달력을 그리지 않고 -안내 한 줄만 내보내고, 달력은 하이드레이션 후에 그린다. 자바스크립트가 꺼진 크롤러가 보는 -것은 "실제 예약 가능 여부와 결제는 아래 예약 창구에서" 뿐이다. - -**구조화 데이터는 건드리지 않았다.** 데모는 JSON-LD 에도 llms.txt 에도 나가지 않는다 — -`makesOffer.availability` 는 여전히 없고(빈 방을 모른다), llms.txt 는 "이 홈페이지는 빈 방 -재고와 결제를 처리하지 않습니다" 를 그대로 말한다. 목업을 AI 에게 예약 창구로 소개하면 -그때부터는 목업이 아니다. - -**연동을 붙일 자리** — `ConfirmPanel` 한 곳이다. 실시간 재고·접수가 생기면 그 함수만 바뀐다. - -**빌더 캔버스도 같이 맞췄다** — 사장님 편집 화면은 여전히 "네이버 실시간 온라인 예약 / -캘린더에서 바로 확정 예약" 을 그리고 있었다. 우리는 실시간 예약을 하지 않는데다, -**에디터에서 본 것과 발행된 사이트가 서로 다른 물건**이었다. -- `booking/BookingCard`: 발행본 구성(날짜 칩 · 도착 시간 · 인원 · 예약 요청 · 전화 창구)의 - 미리보기로 갈아엎었다. 캔버스의 클릭은 "이 섹션을 고른다" 는 뜻이라 상태를 두지 않고 - 첫 칸이 골라진 모습으로 고정한다. 시간 칸은 발행본과 같은 규칙으로 **체크인 fact 가 있을 - 때만** 그린다 -- `booking/BookingBanner` "실시간 캘린더" → "날짜와 시간을 고르고 예약 창구로 이어집니다", - `rooms/RoomCard` "실시간 예약 신청" → "예약 안내 보기", `hero/HeroEditorial` "실시간 예약" - → "예약 안내" -- `LinkChannel.NAVER_BOOKING` 을 orval 생성물에 반영. ★ `npm run orval` 을 그대로 돌리면 - **141파일 6,400줄**이 바뀐다 — 전부 따옴표·줄바꿈 포매팅 드리프트고 스펙 변경은 enum - 한 줄뿐이다. 그래서 생성물을 되돌리고 그 한 줄만 남겼다(실측 2026-09-09) - -**검증** — `tsc·eslint` 통과, `vitest` 51 passed(신규 4건: 날짜가 HTML 에 안 박히는지 · -JSON-LD 무영향 · llms.txt 무영향 · 객실 0개면 안 그림). 실제 발행본 재굽기 후 -`/s/` 에서 데모 껍데기와 안내 문구 확인. - ---- - -## 2026-09-08 — 가짜 발행을 없앴다 — 굽지도 않고 [사이트 열기] 를 그렸다 - -**무슨 일** -발행 모달에서 [발행하기] 를 누르면 "발행 준비가 끝났습니다" 토스트가 뜨고 [사이트 열기] -버튼이 생겼다. **서버를 한 번도 안 불렀고, 그 주소는 404 다.** 목록에도 안 생긴다. -사장님은 발행됐다고 믿는다. - -**왜** -`PublishModal.handlePublish` 가 `publisher.isLive`(= placeId + 토큰)가 거짓이면 서버 호출을 -건너뛰고 `setPublishedUrl(url)` 로 스토어에 주소를 박았다. 그러면 `isDone` 이 참이 되어 완료 -화면이 그려진다. 데모 경로를 위해 둔 분기인데 **로그인한 사장님도 이 길로 온다** — 3단계의 -[수집 없이 다음 단계로](직접 입력)로 나가면 서버에 사업장이 없는 채 에디터까지 가고, -거기서 로그인해도 `placeId` 는 여전히 없다. - -**고친 것** -- 가짜 분기 삭제. `isDone` 은 `state.phase === 'published'` 하나로 줄였다 — 굽지 않은 주소에 - [사이트 열기] 가 붙던 자리가 여기다 -- 발행 불가 사유를 `PublishBlocker`(`signin` · `place`)로 갈라 모달 안에서 말한다. - blocker 가 있으면 주소칸·점검·발행 버튼을 아예 그리지 않는다 -- 비로그인: `/login` 으로 튕기지 않고 모달 안에 로그인 폼을 둔다 — 빌더 스토어는 비영속이라 - 튕기면 만들던 게 날아간다(`EditorSignInGate` 와 같은 이유) -- 로그인 O + 사업장 X: 이유를 말하고 [내 가게 확인하러 가기] → `/builder?step=search`. - 여기서 사업장을 몰래 만들지 않는다 — 생성·검증 순서는 `ensureServerPlace` 한 곳이 소유한다 -- 3단계 버튼을 [발행 없이 화면만 둘러보기] 로 바꾸고 "이 길로 가면 발행이 안 된다" 를 붙였다. - 버튼은 남긴다 — 검증을 못 통과한 사람이 화면을 구경할 길까지 막을 이유는 없다 - -**검증** — 프론트 tsc+eslint 통과. 백엔드가 같은 상황을 어떻게 거절하는지도 확인했다: -검증 안 된 사업장으로 발행하면 `PLACE_NOT_VERIFIED` 다. 서버는 이렇게 분명히 막는데 -프론트만 서버를 안 부르고 성공을 말하고 있었다. - -⚠️ 이 변경의 **코드는 f2dad65 에 섞여 들어갔다** — 같은 레포를 동시에 작업하던 다른 세션이 -커밋할 때 스테이지에 올려 둔 `PublishModal.tsx`·`Step3DataReview.tsx` 를 같이 담았다. -그 커밋 제목은 발행본 목록 주소 얘기라 이 변경을 가리키지 않는다. 기록은 여기에 남긴다. - ---- - -## 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 — 내 사이트 목록에 썸네일·주소·시각 — 발행할 때마다 그림이 바뀐다 - -**무슨 일** -목록 줄이 아이콘·상호·배지·주소 넷뿐이었다. 서버는 이미 `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/.` 로 고정이고 내용만 `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 — 회사(테넌트)를 걷어냈다 — 사장님 계정이 곧 스코프다 - -**무슨 일** -사장님이 가입하면 회사가 하나 생기고 그 회사의 직원이 됐다. 가입 폼은 "상호"를 묻고, -에디터 헤더에는 "이름 · 회사명" 이 붙었다. 쓰는 사람은 사장님 한 명인데. - -**왜 그랬나** -보일러플레이트(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-08 — "예약" 을 누르면 검색 화면이 떴다 — 네이버 예약 주소를 수집해서 쓴다 - -**무슨 일** -발행본의 예약 버튼이 네이버 **플레이스** 링크를 그대로 열었다. 잘해야 가게 홈이라 예약을 한 번 -더 눌러야 하고, 자동 발견이 물어온 URL 이 `map.naver.com/p/search/…`(검색 결과 주소)인 사장님은 -**예약하려고 눌렀는데 검색 화면**을 봤다. 예약하러 온 손님은 거기서 끝난다. - -**근거 — 주소를 지어내지 않아도 된다** -플레이스 모바일 응답(`__APOLLO_STATE__`)의 `ROOT_QUERY.placeDetail(...).naverBooking` 에 -네이버가 예약 주소를 직접 준다(실측 2026-09-08, place 1273971279): - - naverBookingUrl : "https://m.booking.naver.com/booking/6/bizes/1067685" - tabs : [home, feed, menu, booking(예약), review, …] - -★ `bookingBusinessId`(1067685)와 `businessTypeId`(6)로 주소를 **조립하지 않는다.** 조립하면 -예약을 받지 않는 업소에도 그럴듯한 주소가 생기고, 눌러서 빈 화면을 본 손님은 그 가게가 예약을 -안 받는 줄로 읽는다. 응답이 `naverBookingUrl` 을 줄 때만 준 그대로 쓴다(미사용 업소는 null). - -**바꾼 것** -- `LinkChannel.NAVER_BOOKING = 7` (백엔드 enum · shared enum · init.sql 주석). 플레이스와 가른 - 이유는 성격이 다르기 때문이다 — 이건 **예약 화면 그 자체**다 -- `collector/base.py`: `RawSource.booking_url` — 채널이 스스로 알려준 예약 주소를 싣는 자리 -- `naver_place_adapter._booking_url()`: 위 노드에서 읽는다. 키에 질의 인자가 통째로 박혀 있어 - (`placeDetail({"input":…})`) 이름으로 못 찾으므로 접두사로 찾는다 -- `collect_service._store_booking_link()`: 예약 채널 링크로 등록하고 **자동 확정**한다. - 근거는 `discover_naver_place` 와 같다 — 이미 확정된 플레이스가 자기 예약 주소로 내놓은 - 값이라 남의 가게가 섞일 경로가 없다. 여기서 클릭을 한 번 더 받으면 그 사이 예약 버튼은 - 계속 검색 화면으로 간다 -- `site/seo/jsonld.ts` `BOOKING_CHANNELS`: **순서가 우선순위**가 됐다(네이버 예약 → 야놀자 → - 여기어때 → 플레이스). `bookingChannelUrl` 이 이 순서로 고르므로 화면 버튼과 - `makesOffer.url`·`potentialAction` 이 같은 곳을 가리킨다 -- `site/lib/derive.ts`: 예약 버튼을 같은 순서로 정렬하고, **검색 결과 주소는 뺀다** — - 예약하러 온 사람에게 검색 화면을 주는 건 링크가 없는 것보다 나쁘다. 링크가 하나도 없으면 - "온라인 예약 채널은 등록되지 않았습니다" 로 전화만 남는다는 것을 말해 준다 -- `bookingCtaLabel()`: `${채널}에서 예약` 을 일괄로 쓰면 "네이버 예약에서 예약" 이 된다. - 그리고 이 채널만 누르는 즉시 예약 화면이므로 버튼이 그 차이를 말해야 한다 — - "네이버 예약으로 바로 예약하기" -- 빌더도 이 채널을 안다(`useCollectFlow` 라벨, `ChannelUrlInput` 의 호스트 판정) - -**검증** — 실제 네이버 응답으로 어댑터 확인: `RawSource.booking_url = -https://m.booking.naver.com/booking/6/bizes/1067685` · 예약 노드가 없는 응답에서는 None. -`tsc·eslint` 통과, `vitest` 47 passed(신규 4건: 채널 우선순위 · 버튼 문구 · JSON-LD 대상 · -검색 URL 배제). - -## 2026-09-07 — `.env.example` 그대로 쓰면 로컬 발행이 안 됐다 — 함정 둘 - -클론 직후 문서대로 `cp .env.example .env` 하고 `docker compose up -d` 한 다음 발행을 걸어 봤다. -**게이트는 통과하는데 발행만 실패한다.** 두 가지가 겹쳐 있었다. - -**1) `DB_HOST=127.0.0.1`** — 컨테이너 안의 127.0.0.1 은 그 컨테이너다. compose 기본값은 -`host.docker.internal` 인데 `.env` 가 그걸 덮어쓴다. 증상이 고약하다: API 는 `/healthz` 가 -DB 를 안 보므로 **200 healthy** 로 뜨고, **워커만 조용히 재시작을 반복한다** — 화면은 멀쩡하고 -발행 잡만 영원히 안 돈다. - -**2) 줄 끝 주석이 값이 된다.** compose 의 `env_file` 은 `KEY= # 설명` 을 "빈 값"으로 읽지 -않는다 — 값이 `"# 설명"` 이다. 그래서 Azure 를 끈 로컬에서 `is_configured()` 가 참이 되고 -발행 잡이 업로드를 시도해 `Connection string is either blank or malformed` 로 죽었다. -같은 모양이 5개였다: `COLLECT_USE_PERPLEXITY`(값 `0` 이 `"0 # ..."` 가 된다) · -`KAKAO_REST_API_KEY` · `TOUR_API_KEY` · `INDEXNOW_KEY` · `AZURE_STORAGE_CONNECTION_STRING`. - -**3) 앱이 스스로 크로스 오리진을 만든다.** `nginx/site.conf` 는 `/v1` 을 같은 오리진으로 -프록시하고 주석에도 "앱과 같은 오리진이라 프리플라이트가 아예 발생하지 않는다" 고 적혀 -있는데, compose 의 빌드 인자 기본값이 `VITE_API_BASE_URL=http://localhost:9800` 이었다. -`:80` 으로 앱을 열면 번들이 `:9800` 을 부르므로 크로스 오리진이 되고, `CLIENT_URL` 기본값 -(3000~3005)에 `http://localhost` 가 없어 **로그인만 계속 실패한다.** 증상이 사람을 속인다 — -서버는 200 에 토큰까지 내려보내고, 브라우저가 `allow-origin` 이 없어 그 응답을 버리므로 -화면에는 "로그인에 실패했습니다" 만 뜬다. 비밀번호를 의심하게 된다. - -**고친 것** -- `.env.example`: `DB_HOST` 기본값을 `host.docker.internal` 로. 값 뒤 주석은 전부 **윗줄로** - 올리고, 파일 머리에 "값 뒤에 주석을 붙이지 않는다" 를 근거와 함께 박았다 -- `.env.example` · `docker-compose.yml`: 앱이 부르는 API 주소 기본값을 **앱과 같은 오리진** - (`http://localhost`)으로. CORS 를 허용해서 뚫는 게 아니라 **크로스 오리진을 만들지 않는다** — - nginx 가 이미 같은 오리진으로 프록시하고 있었다. `PUBLIC_API_BASE_URL` 을 주석이 아니라 - 값으로 내놨다(주석으로 두면 compose 기본값이 이기고, 그 기본값이 문제였다) - -**검증** — 새 DB(`web4ai_db`)에 `init.sql` 적용 → `docker compose down -v` 후 `up -d --build` → -번들에 `localhost:9800` 참조 0건 · `POST http://localhost/v1/auth/login` 200(프리플라이트 없음) · -프리렌더가 기동하며 payload 2건 재굽기 → `/` `/s/` `/s/` 전부 200. 그리고 → -`scripts/demo_build.py` 로 발행: 게이트 통과 · `published: true` · 프리렌더가 굽고 -`http://localhost/s/` 200. ★ 참고로 `demo_build.py` 는 자기 안에서 워커를 한 번 돌리는데, -compose 워커가 잡을 먼저 집어가므로 **스크립트 출력은 "게이트 거부"로 보인다** — 실제 결과는 -`job.jobs.result` 와 워커 로그에 있다. - -## 2026-09-07 — 숙박 예약 구성 — "실시간 예약" 섹션이 전화번호 한 줄이었다 - -**왜** -숙박으로 발행하면 서버 기본표(`site_payload._DEFAULT_THEME`)가 `booking` 섹션을 켠다. 그런데 -발행본의 `BookingSection` 이 읽는 fact 는 `reservation_required`·`reservation_channel` 두 개이고, -**둘 다 숙박 스키마(`lodging.json`)에 없다.** 그래서 펜션·민박 페이지의 "실시간 예약" 섹션에는 -전화번호 한 줄만 남았다 — 요금도, 인원도, 취소 규정도, 예약 창구도 없었다. 숙박은 예약이 곧 -매출이고 "얼마예요 / 몇 명까지 / 어떻게 예약해요" 가 이 업종 질의의 대부분인데, 그 답의 근거가 -페이지에 없으면 AI 는 OTA 후기에서 추측한다. - -★ **예약을 처리하게 만든 게 아니다.** 빈 방 재고도 결제도 갖지 않는다([PRODUCT.md 6절](PRODUCT.md) -— "사이트는 예약 채널로 보낸다"). 날짜 선택기·예약 폼을 그리지 않았다 — 없는 기능을 화면으로 -흉내내면 손님은 예약한 줄 알고 안 오고, 그 전화는 사장님이 받는다. 대신 **예약에 필요한 사실 + -실제로 예약이 되는 창구**를 한자리에 모았고, "여기서 결제되지 않는다"를 화면 맨 앞과 llms.txt 에 -명시했다. - -**바꾼 것** -- `site/src/sections/StayBookingSection.tsx` (신규) — 객실별 요금·인원 / 예약 창구(전화 + 확정 - 채널) / 예약 전 확인(체크인·체크아웃·취소환불·추가인원·프런트 시간·취사·반려동물·흡연). - 근거가 하나도 없으면 섹션째 안 나간다 -- `site/src/lib/derive.ts` — `stayBookingView()` 가 **그릴지 말지까지** 판단한다. 상단 내비·하단 - 탭이 같은 함수를 본다 — 세 곳이 각자 판단하면 눌러도 아무 일 없는 "예약" 탭이 생긴다. - 예약 창구로 나가는 채널은 문의 목록에서 뺀다(네이버 플레이스가 두 번 보였다) -- `site/src/seo/jsonld.ts` — `unitBaseRate()` 를 **요금 숫자의 단일 출처**로 만들고 화면과 - `makesOffer.price` 가 같이 쓴다(각자 계산하면 절대규칙 3 위반으로 발행이 멈춘다). - `makesOffer`(객실별 1박 요금) · `potentialAction: ReserveAction`(확정 채널만) 추가. - **`availability` 는 넣지 않았다** — 빈 방을 모르는데 InStock 을 주장하면 그게 거짓이다 -- `site/src/seo/llms.ts` — 숙박 `## 예약` 블록. LLM 은 위에서부터 읽는다. 예약 경로가 "공식 채널" - 절 맨 아래에만 있으면 답에 안 실린다 -- `frontend/src/data/industryData.ts` · `backend/services/site_payload.py` — 숙박 기본 섹션 이름을 - **"실시간 예약" → "예약 안내"**. 실시간 예약을 하지 않는데 제목이 그렇게 말하고 있었다. - 두 파일은 `tests/test_site_theme.py` 가 1:1 로 묶어 두므로 같이 고쳤다 -- 데모 fixture 의 theme 에 `rules`·`booking` 을 넣었다 — 서버 기본표에는 있는데 fixture 에만 - 없어서, 개발 서버로는 이 두 섹션을 아예 볼 수 없었다 - -**곁에서 나온 것 — 데모 payload 는 원래 굽히지 않았다** -`npm run prerender`(payload 미지정 = 데모)가 **절대규칙 3 대조 9건으로 실패**하고 있었다. -내 변경 전에도 같은 건수로 실패했다(main 에서 재현 확인). -1. `verify.ts` 가 URL 을 **원본 HTML 문자열**에서 찾았다. 속성으로 나갈 때 `&` 가 `&` 로 - 이스케이프되므로 쿼리스트링 있는 이미지 URL 은 **화면에 있는데도** 절대 안 찾아진다. - → 엔티티를 되돌린 사본에서도 찾아본다. 표기 차이는 거짓이 아니다(숫자 `asShown()` 과 같은 이유). - 되돌린 사본에서도 못 찾으면 그대로 실패다 — 느슨해지지 않았다. -2. `unitCode: 'MTK'`(㎡ 의 UN/CEFACT 코드)를 본문에서 찾고 있었다. 한국어 페이지에 'MTK' 가 - 찍힐 일은 없다 — `priceCurrency`('KRW')와 같은 종류의 메타값이라 `STRUCTURAL` 로 옮겼다. - ★ 사람이 읽는 `unitText` 는 옮기지 않았다 — 그건 화면에 있어야 하는 말이다. - -**검증** — `tsc·eslint` 통과, `vitest` 43 passed(신규 21건: 예약 뷰·발행 HTML·JSON-LD 대조·llms.txt). -데모 payload 재굽기 성공(1개 중 1개) → `npm run serve` 로 `/s/moonlight-stay-jeju` 200 확인. -백엔드 pytest 는 이 환경에 venv 가 없어 못 돌렸다 — 에디터↔서버 섹션표 parity 는 그 테스트와 -같은 방식으로 손으로 대조했다(stay: `예약 안내` 양쪽 일치). - -## 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 가 끊겼다 - -**무슨 일** -바로 아래 항목(옛 해시 자산 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` - -**왜** -레포를 `castad/o2o-web4ai` → `Web4ai/o2o-site-AEO` 로, 공개 주소를 `w4ai.o2o.kr` → -`web4ai.o2osolution.ai` 로 옮겼다. 옛 주소는 앞단에 vhost 가 없어 전 경로가 Apache 404 였다 — -그런데 canonical·og:url·sitemap 이 전부 그 주소를 가리키고 있었다. **화면은 멀쩡하고 기계가 -읽는 값만 틀린** 상태라, 검색엔진 등록을 아무리 해도 색인이 안 되는 종류다. - -**바꾼 것** -- 기본 호스트를 쓰는 자리 전부(`site_payload.DEFAULT_HOST` · compose 의 `:-` 기본값 4곳 · - `vite.config.ts` allowedHosts · `.env.example` 둘 · `check_search_ready.py` · 데모 픽스처) -- `docs/SERVERS.md`: 배포 경로 `~/data2/o2o-site-AEO` · 새 remote · 공개 주소 절 -- `init.sql`: `site.sites.thumbnail_url` 을 ALTER 절에 추가 — 아래 참조 -- `solution/frontend/public/google60b514c02fd6af4e.html`: 새 호스트로 다시 받은 구글 소유확인 - -**밟은 함정 둘** -1. **`origin` 은 payload JSON 에 구워진다.** `.env` 만 고치고 프리렌더를 돌리면 안 바뀐다 — - 백엔드에서 재발행하거나 payload 의 `origin` 을 직접 고쳐야 한다. -2. **`init.sql` 은 DB 최초 생성 때만 돈다.** 41커밋을 건너뛰며 배포했더니 `users.provider` 와 - `sites.thumbnail_url` 이 없어 로그인·쇼케이스가 통째로 죽었는데 **HTTP 는 200 이었다.** - `thumbnail_url` 은 `CREATE TABLE` 에만 추가돼 있어서 **새 DB 는 되고 기존 DB 만** 깨졌다. - -**검증** — 새 호스트로 canonical·og:url·robots.txt·sitemap 3건 전부 확인, 로그인·쇼케이스· -장소검색 정상, 스키마 드리프트 0. - ---- - -## 2026-09-03 — 랜딩 · 요금 · 쇼케이스 — 로그인 전 화면이 생겼다 - -**왜** -`/` 가 곧장 위저드로 튀어서, 이 제품이 무엇을 파는 물건인지 말할 자리가 한 곳도 없었다. -처음 온 사람이 업종 선택 화면부터 만난다. - -**한 일** -- `/` 는 비로그인이면 랜딩, 로그인이면 `/sites`. `/pricing` · `/showcase` 신설 -- `MarketingShell` — 사이드바 없는 문서형 껍데기. `AppShell` 은 작업 화면이라 나눴다 - (b07ade2 가 온보딩에서 사이드바를 뺀 것과 같은 판단) -- 랜딩 상단은 **상호명 한 칸**이다. 업종 칩은 "누구를 위한 서비스인가"를 말하는 용도이고 - 고르지 않아도 된다 — 업종은 검색 결과가 정한다 -- 쇼케이스는 발행 썸네일을 그대로 건다. **예시 데이터로 채우지 않는다** — 이 섹션이 파는 건 - "진짜로 나갔다"는 사실 하나라, 가짜를 걸면 그 자리에서 가치가 0 이다. 없으면 섹션을 감춘다 -- 요금은 플랜 하나(70만원/월). 비교표를 만들지 않는다 — 고를 것이 가격대가 아니다 - -**검증** — tsc·eslint·vite build 통과. - -## 2026-09-03 — 상호명 검색을 로그인 앞으로 · 업종은 LLM 없이 정한다 - -**왜** -랜딩 첫 화면에서 상호명을 치게 하려면 검색이 로그인 앞에 있어야 하는데, -후보 조회는 `place_id` 와 토큰을 둘 다 요구했다(`place.py` 확정 경로). 로그인 관문을 -에디터 진입 하나로 되돌려 놓고도 API 는 그대로였다. -그리고 업종은 사장님에게 고르게 하고 있었는데 — 경계(베이커리 카페, 브런치집)에서 멈춘다. - -**한 일** -- `GET /v1/place/search` 신설(인증 없음). 사업장을 만들지도, 우리 DB 를 읽지도 않는다. - 확정 경로(`/{place_id}/verify/candidates`)는 인증을 그대로 둔다 — 남의 place_id 존재 - 여부까지 열 이유가 없다 -- `place_category.guess_category()`: 카카오 `category_group_code`(AD5·CE7·FD6) 우선, - 없으면 분류 문자열. **LLM 호출 0건** — 상호명 검색 응답에 이미 들어 있던 값이다 -- 못 정하면 `None`. 억지로 고르지 않는다 — 업종은 수집 스키마와 JSON-LD 타입을 통째로 - 정해서 틀리면 되돌리는 비용이 크다. HP8(병원)은 피부과·성형외과일 때만 받는다 -- `rate_limit`: 인증 없이 유료 외부 API 를 부르는 경로라 IP 당 분당 20회. - 프로세스 메모리라 완전하지 않다(앞단 nginx 가 제대로 된 자리) - -**검증** — 전체 562 passed. 공개 응답에 place_id·전화·좌표가 안 나가는 것, -검색만으로 사업장이 생기지 않는 것을 테스트로 고정. - -## 2026-09-03 — 발행하면 썸네일이 남는다 (랜딩 쇼케이스용) - -**왜** -랜딩에 "이렇게 만들어졌습니다" 를 보여줄 그림이 없었다. 사이트는 발행되는데 그 결과물을 -가리킬 이미지가 어디에도 저장되지 않아, 쇼케이스를 만들려면 매번 사람이 캡처를 떠야 했다. - -**썸네일은 스크린샷이 아니라 그 사이트의 대표 사진이다** -헤드리스 브라우저는 봇 탐지 우회 우려로 영구 금지고([DECISIONS 1-1](DECISIONS.md)), -워커(python:3.12-slim)·프리렌더(node:24-alpine) 어디에도 Chromium 이 없다. 넣으면 이미지가 -수백 MB 늘고 금지해 둔 도구를 상비하게 된다. 대신 `og:image` 로 나가는 **대표 사진**을 그대로 -옮긴다 — 검색 결과에 뜨는 그림과 쇼케이스 카드가 같아진다. 대표 사진 선정 규칙은 -`site_payload.primary_media()` 한 곳뿐이라 두 곳이 갈릴 수 없다. - -**한 일** -- `services/site_thumbnail.py` 신설. 대표 사진을 httpx 로 받아(10초 상한 · 리다이렉트 3회 · - image/* 만 · 5MB 상한) `/thumbs/.` 로 올린다. 기존 - `AZURE_STORAGE_CONNECTION_STRING` 을 그대로 쓴다 — 새 자격증명 체계를 들이지 않았다. - ★ 사이트 경로(`s//`) 안에 두지 않는다: `azure_static._remove_stale_site_files()` 가 - 매 발행마다 그 경로를 통째로 교체하므로 다음 발행에서 조용히 사라진다. -- `build_service`: `azure_static.publish()` 직후 · IndexNow 통보 전에 저장하고, - 발행 상태 전이 UPDATE 에 `thumbnail_url` 을 실어 보낸다(UPDATE 는 그대로 한 번). - 실패해도 발행을 되돌리지 않는다 — 정적 파일은 이미 올라갔다(`emit_payload` 와 같은 원칙). - 못 만들면 키를 넣지 않아 지난 발행의 그림이 남는다. -- `GET /v1/showcase` 신설(**인증 없음**, 랜딩이 부른다). 발행된 사이트만 최신순, - 기본 12건·상한 48건. 나가는 것은 상호명·업종·지역(시·군·구까지)·발행 주소·썸네일뿐이다 — - place_id·company_id·전화번호·상세 주소는 싣지 않는다. 무엇을 내보낼지 고르는 자리를 - `services/showcase_service.py` 한 곳에 모아 경계를 눈에 보이게 뒀다. - 어드민 진입점(:9801)에는 마운트하지 않는다. - -**곁가지로 고친 것 — 브랜치에 이미 깨져 있던 테스트 4건** -- `conftest.fake_renderer` 가 늘 `ok=True` 를 돌려줬다. 진짜 렌더러는 고유 콘텐츠 0건이면 - 페이지를 쓰지 않는데(prerender.ts `NoUniqueContentError`), 대역이 그 실패를 흉내내지 않아 - 백엔드가 그 사유를 NO_UNIQUE_CONTENT 로 되짚는 경로가 통째로 안 돌고 있었다. -- `test_snapshot` 이 "region_code 가 없으면 지역 정보 없음" 을 기대했다. 지금은 도로명주소에서 - 유도한다(`snapshot._local_contents`) — 유도 동작에 테스트가 없었다. 둘로 갈라 채웠다. - -**검증** — `pytest` 전체 552 passed. -## 2026-09-02 — 로그인한 사장님의 홈(내 사이트 · 내 정보) · 위저드에서 사이드바 제거 - -**왜** -로그인해도 갈 곳이 없었다. `/` 는 무조건 위저드였고, 사업장 목록은 내부 운영 앱(admin)으로 -나가서 사장님 앱에는 그 경로가 아예 없다. 만든 사이트를 다시 여는 유일한 길이 -`/builder?placeId=` 를 기억하는 것이었다. - -아임웹을 보면 계층이 둘로 갈려 있다 — **계정 레벨**(내사이트 목록 · 마이페이지)과 -**사이트 레벨**(그 사이트의 관리자 페이지 · 디자인모드). 우리 에디터가 그 사이트 레벨이므로 -비어 있던 것은 계정 레벨이다. 그리고 아임웹도 **사이트 개설 흐름에는 계정 사이드바를 붙이지 -않는다** — 아직 사이트가 아닌 것에 사이트 메뉴를 얹을 수 없어서다. - -**한 일** -- `GET /v1/site/list` — places LEFT JOIN sites LEFT JOIN site_versions 한 번. 사업장 목록으로 - 그리면 줄마다 사이트를 다시 물어 N+1 이다. 사이트가 아직 없는 사업장도 내려간다 — - 빠지면 위저드를 걸어오다 만 가게를 다시 찾을 길이 없다. - `render`(정적 파일이 실제로 있는지)는 넣지 않았다 — 보고서 **파일**을 읽는 값이라 줄 수만큼 - 파일 IO 가 된다. 단건(`Res_Site`)이 계속 소유한다. -- `/sites` 내 사이트 · `/account` 내 정보. `/` 는 로그인 여부로 갈린다(비로그인은 그대로 위저드). -- ⋯ 메뉴는 **[발행 내리기] 하나**다. 삭제는 두지 않았다 — 색인된 페이지를 404 로 만들면 그 자리를 - 다시 OTA 가 가져가고, 되돌릴 방법이 사장님에게 없다. -- 위저드에서 `AppShell`(사이드바)을 걷어내고 얇은 상단 바로 바꿨다. 사이드바는 계정 메뉴라, - 만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다. 진행은 `WizardSteps` 가 - 이미 보여주므로 거기 필요한 건 로고와 나가는 길 하나다. -- 에디터 헤더에 [← 내 사이트]. `BuilderPage` 가 "내 사이트 관리가 생기면 그때 잇는다"고 - 비워 뒀던 자리다. - -**검증** — 백엔드 테스트 5건 추가(사이트 없는 사업장 · 조인 · 회사 격리 · 재빌드 판정이 단건과 -일치 · 비로그인 401), 539 passed. `tsc·eslint·vite build` 통과(frontend·admin). -위저드에 사이드바가 사라진 것은 브라우저에서 확인. - -## 2026-09-02 — 계절별 추천 하루는 지금 계절만 · 간절기엔 두 계절 - -**왜** -네 계절 코스를 다 늘어놓으니 손님 앞에 열두 개가 깔렸다. 그건 추천이 아니라 목록이다. -12월에 온 손님에게 봄 벚꽃 코스를 권할 이유가 없다. - -**한 일** -- `shared/currentSeasons()` — 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울. - **계절 첫 달의 전반(1~15일)은 간절기**로 보고 앞 계절과 함께 둘을 돌려준다. - 9월 초에 여름 코스만 보이면 지난 계절이고, 가을만 보이면 아직 이른 코스다. -- 발행본은 **HTML 에 전 계절을 굽고 화면에서만 접는다**(`hidden`). 두 가지 이유다 — - ① 정적 페이지는 한 번 구우면 몇 달 산다. 굽는 시점의 계절을 박으면 12월에도 가을이 걸린다. - 그래서 계절 판정을 **브라우저에서** 한다(일력의 '오늘'과 같은 수법). - ② 이 사이트의 존재 이유가 인용이다. 지우면 검색·AI 가 나머지 계절을 못 읽는다. -- 지금 계절에 코스가 없으면(사장님이 그 계절을 안 채웠다) 접지 않고 전부 보여준다 — - 빈 섹션보다 철 지난 코스가 낫다. -- 빌더는 탭을 그대로 두되 **지금 계절로 열리고**, 탭에 '·지금' 표시와 - "손님 화면에는 지금 계절만 나갑니다" 한 줄을 붙였다. 안 적으면 사장님은 손님도 네 계절을 - 다 본다고 오해한다. - -**검증** — `tsc·eslint` 통과(frontend·site). 경계 12일자 단위 확인 -(3/5→겨울·봄, 3/20→봄, 6/7→봄·여름, 9/2→여름·가을, 9/16→가을, 12/10→가을·겨울). -실물 payload(스테이,머뭄 `/s/stay`, 9코스 4계절)로 구워 **오늘(9/2) 여름·가을만 보이고 -봄·겨울은 `hidden`, HTML 에는 네 계절 전부** 있는 것을 브라우저에서 확인. - -## 2026-09-02 — 계절별 추천 하루(시각을 계산해 주는 아이템) · 아이템에서 레트로 하드코딩 제거 - -**왜** -아이템 열 개가 전부 갱지색·주(朱)잉크·간판체를 hex 와 폰트명으로 박고 있었다. 사장님이 템플릿을 -매거진으로 바꿔도 **아이템 섹션만 레트로로 남아** 화면이 두 벌로 보였다. 아이템은 레트로 전용 -부품이 아니라 어느 템플릿에나 들어가는 섹션이다. -그리고 발행본은 **색만** 템플릿을 따랐다 — `theme` 계약에 생김새(look)가 없어서, 레트로를 골라도 -발행 페이지는 늘 같은 고딕으로 나갔다. 캔버스와 발행본이 다르게 보이는 가장 큰 이유였다. - -**한 일** -- 아이템 1종 추가 — **계절별 추천 하루**(`planner.podium`). 계절 탭 + 1·2·3위 카드. - 기존 `schedule` 과 축이 다르다: 저쪽은 사장님이 시각을 적고, 여기는 **시각을 계산한다**. - 사장님은 출발 시각과 "몇 분 걸리나"만 적고, 출발을 당기면 하루가 통째로 밀린다. - 조립 규칙(`planDay`·`plannerTop`·`plannerSeasons`)은 파서와 같은 이유로 `@o2o/shared` 한 벌이다 — - 빌더와 발행본이 같은 조건에서 **같은 시각**을 내야 한다. - 밤 9시를 넘기는 칸은 넣지 않고 **뺐다고 화면에 밝힌다**(숨기면 사장님은 왜 없는지 모른다). -- 아이템 색·서체를 전부 템플릿 토큰(`--tpl-*`)으로. `retro/common.tsx` → `items/common.tsx`, - `RETRO_*` 상수 → `ITEM_*` 토큰. 글자 단계는 stone-400/500/600 대신 **불투명도**로 만든다 — - 팔레트가 바뀌어도 위계가 유지된다. 질감(도넛판 홈·톱니·필름 구멍)도 `currentColor` 로 판다. -- **`SiteTheme.look` 계약 추가** — 서체·모서리·테두리 두께·그림자·섹션 여백이 발행본까지 간다. - 프론트가 저장하고(`toThemePayload`), 서버는 해석 없이 싣고(`_theme`), `seo/head.ts` 가 `--tpl-*` 로 심는다. - 발행본 `.serif`·`body` 도 이 토큰을 읽는다. -- 웹폰트는 **템플릿이 쓰는 것만** 내려보낸다(서체 스택을 훑어 아는 것만). 전부 항상 실으면 - 쓰지도 않는 서체가 모든 발행 사이트의 첫 렌더를 늦춘다. -- 색 유도식(`deriveSurfaces`)을 `@o2o/shared` 로. 캔버스·쇼케이스·**발행본**이 같은 식을 써야 - 미리보기가 거짓말을 하지 않는다. 프론트 `lib/color.ts` 는 재수출만 남겼다. - -**밟은 함정** -- 강조색을 그대로 쓰면 팔레트에 따라 큰 날짜 숫자와 배지가 **사라진다**(연한 accent + 밝은 바탕). - → `color-mix(accent 70%, currentColor)` — 색조는 남고 대비만 확보된다. 어두운 면에서는 밝은 쪽으로 붙는다. -- 순위 배지를 accent 로 채웠더니 같은 이유로 글자가 안 보였다. 1위만 **글자색**으로 채운다. -- '확인/확인필요' 배지는 디자인이 아니라 신호다. 신호색은 지키되 둘레 글자색을 섞어 대비만 맞춘다. - -**검증** — `tsc·eslint·vite build` 통과(frontend·admin·site), site 테스트 17 passed. -쇼케이스에서 팔레트를 바꿔 제목 서체·날짜 색이 함께 바뀌는 것 확인. -실물 프리렌더(레트로 look + planner): `` 에 `--tpl-font-heading: 'Gugi'…`·`--tpl-border-width: 2px`, -`family=Gugi&family=Gowun+Batang` 링크, 계절 묶음 여름·가을, 순위 1·2위, -계산된 시각(09:30 출발 → 09:45 도착 → 11:15 → 11:25…) 확인. 고유 콘텐츠 12건 · ok=true. -**옛 payload(look 없음)** 로 다시 구워 서체 링크가 예전 두 벌 그대로이고 look 토큰이 안 실리는 것까지 확인. -백엔드는 이 환경에 PostgreSQL 이 없어 pytest 를 못 돌렸다 — `_theme`·`_sections` 는 함수 단위로 직접 확인했다. - -## 2026-09-02 — 붙여넣기 아이템 다섯을 더하고, 아홉 개를 발행 사이트까지 내보낸다 - -**왜** -아이템 카탈로그에서 고른 여덟 중 넷(가요·일력·승차권 + 스케줄)만 있었다. 나머지 다섯은 -빌더에 칸 자체가 없었다. 더 큰 구멍은 그 아래에 있었다 — **아홉 개 전부 발행본에 안 나갔다.** -`SectionSetting` 계약에 `data` 가 없어서, 사장님이 채운 JSON 이 payload 경계에서 통째로 버려졌다 -(소개문 `body` 와 같은 사연). 빌더에서는 보이는데 발행하면 없는 섹션이었다. - -**한 일** -- 아이템 5종 추가 — 인물 열전(필름 스트립) · 시간의 골목(가로 연표) · 문학 서가(책등·세로쓰기) · - 오늘의 엽서(엽서 뒷면) · 뒤집어 보는 질문(갱지 시험지 플립). - `dataSpec` 에 스키마·프롬프트·예시, `registry` 에 배리에이션 한 줄씩. - [+ 섹션 추가] 목록은 `dataSpec` 에서 파생돼(addable.ts) 따로 손댈 곳이 없다. -- **읽는 쪽 계약을 `@o2o/shared` 로 옮겼다**(`lib/section-data.ts`) — 항목 타입 · `parseSectionData`. - 같은 JSON 을 빌더와 발행 사이트가 함께 읽는다. 파서를 각자 두면 슬러그 규칙처럼 조용히 어긋난다. - 빌더에는 **쓰는 쪽**(프롬프트·예시·라벨)만 남았다. -- `SectionSetting.data` 계약 추가 · `site_payload._sections()` 가 그대로 실어 보낸다(서버는 파싱하지 않는다). -- 발행 사이트에 아이템 섹션 아홉(`site/src/sections/items/`). **인터랙션은 옮기지 않았다** — - 캔버스의 턴테이블은 '지금 한 곡'만 펴는데 그러면 나머지 곡의 문장이 HTML 에 없다. - 이 사이트의 존재 이유가 AI·검색의 인용이라 발행본은 전 항목을 펴고 가로로만 민다. -- 프리렌더 고유 콘텐츠 계수에 아이템 항목을 넣었다. 안 세면 "곡을 여덟 개 채웠는데 - 고유 콘텐츠 0건으로 발행이 막힌다"가 된다 — `intro.body` 와 같은 구멍이다(백엔드 fake 도 같이). -- 간판체(Gugi)는 **아이템을 실제로 쓰는 사이트에만** `` 로 내려보낸다. 서체 하나가 - 모든 발행 사이트의 첫 렌더를 늦출 이유가 없다. - -**안 한 것** -레트로 템플릿 시드(`defaultSectionTypes`)는 넷 그대로 뒀다. 붙여넣기 아이템은 내용이 없으면 -빈 섹션이라, 아홉을 시드에 박으면 아무도 안 쓰는 칸이 늘 붙어 있게 된다(addable.ts 의 근거). - -**검증** — `tsc·eslint·vite build` 통과(frontend·admin·site), site 테스트 17 passed. -실물 프리렌더: 아이템 아홉이 든 payload → `ok=true`, 고유 콘텐츠 18건, 발행 HTML 에 아홉 섹션과 -본문 문장 전부 포함, `family=Gugi` 링크 있음. 같은 payload 에서 아이템을 빼면 8건 · Gugi 링크 없음. -백엔드 pytest 는 이 환경에 PostgreSQL 이 없어 전 건 연결 오류로 못 돌렸다 — -바꾼 `_sections()` 와 conftest 계수는 함수 단위로 직접 돌려 확인했다. - ---- - -## 2026-09-02 — 회원가입과 구글 로그인 - -**한 일** -- `POST /v1/auth/signup`(id/pw) · `POST /v1/auth/google` 추가. 로그인 화면에 구글 버튼과 - 가입 링크, `/signup` 화면. 내부 운영 화면은 `selfServe={false}` 로 둘 다 안 뜬다. -- `company.users` 에 `provider`(AuthProvider) · `provider_uid`(구글 sub). `password` 는 NULL - 허용(소셜 계정), `id` 는 20 → 64자(`google_` 가 20자를 넘는다). -- 에디터(6단계) 상단 바에 로그인한 사용자와 [로그아웃]. 위저드는 AppShell 사이드바가 - 들고 있었는데 에디터는 전체 화면이라 **신원도 나가는 길도 화면에서 사라져 있었다.** - -**왜 가입부터 만들었나** -계정 생성 API 가 아예 없었다 — 그동안 `users` 를 손으로 INSERT 했다. 로그인 화면은 있는데 -그 뒤에 설 계정을 만들 방법이 제품에 없는 상태였다. 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개** -로 정의했다. `users.company_id` 가 NOT NULL 이고 모든 도메인이 company 로 스코프되기 때문이다. - -**로그인 관문은 에디터 진입 그대로다** -한때 `/builder` 를 통째로 `RequireAuth` 뒤로 옮겼다가 되돌렸다(5ef3e5a). `/` 가 자기 화면 없이 -`/builder` 로 넘기기만 하므로 **문 앞 가드는 곧 루트 가드**이고, 앱을 열자마자 로그인 화면이 된다. -관문은 `EditorSignInGate`(969fb67) 한 자리다. - -**밟기 쉬운 자리** -- **`GOOGLE_CLIENT_ID` 는 백엔드와 프론트가 같아야 한다.** 백엔드는 이 값으로 구글 토큰의 - 수신자(`aud`)를 대조한다 — 이 검사가 없으면 **다른 서비스에 발급된 진짜 구글 토큰**으로 - 우리 계정에 들어온다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. -- **같은 이메일이라도 id/pw 계정과 구글 계정을 자동으로 잇지 않는다.** 이으면 계정 선점이 - 된다 → [DECISIONS.md 1-5](DECISIONS.md) -- 소셜 계정은 `password` 가 NULL 이다. id/pw 로그인 경로에서 먼저 끊지 않으면 해시 검증이 - None 을 만나 500 이 난다. -- `provider` 에 `server_default` 를 같이 줬다. ORM default 는 raw INSERT(테스트 시드)에 안 먹어서 - NOT NULL 컬럼이면 그 경로가 통째로 깨진다. -- **init.sql 에서 새 컬럼의 인덱스는 맨 끝 ALTER 섹션에 둔다.** 인덱스 절이 ALTER 보다 위라, - 기존 DB 에서는 아직 없는 컬럼을 가리켜 스크립트가 통째로 멈춘다(실측으로 밟았다). - -**이미 도는 DB 가 있으면** `postgres-init/init-data/init.sql` 을 다시 적용한다. - -**아직 못 한 것** — 실제 구글 계정 로그인. `GOOGLE_CLIENT_ID` 가 있어야 버튼이 뜬다. -버튼 렌더까지는 확인했다(빌려온 client_id 로). - -**검증** — 백엔드 auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료· -`email_verified`·본문 변조 거절). 브라우저: 가입 → 위저드 진입 → 사이드바 표시 → 에디터 -상단 바 표시 → 로그아웃. `tsc`·`eslint`·`vite build` 통과. - -## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍 - -**왜** -에디터의 소개 섹션 본문은 `sites.theme` 에 저장됐지만 발행 payload 경계에서 버려졌고, -프리렌더도 고유 콘텐츠로 세지 않았다. 사장님이 소개를 써도 발행 화면은 0건이라며 거부했다. - -**한 일** -- `SectionSetting.body` 계약을 추가하고 저장값을 payload 까지 전달 -- 소개 본문을 발행 HTML에 표시하고, 켜진 소개 섹션의 8자 이상 본문만 고유 콘텐츠로 계수 -- 고유 콘텐츠 0건과 JSON-LD 불일치, 계수 실패를 서로 다른 발행 사유로 분리 - -**검증** — 직접 입력 소개문만 있는 발행 경로 회귀 테스트 추가. - -## 2026-09-02 — 템플릿이 색만 바꾸던 걸 끝냈다 (5개 → 3개) - -**왜** -업종마다 템플릿이 다섯이었는데 넷이 "흰 바탕 + 고딕 + 둥근 모서리"에 색조만 달랐다. -고르는 화면의 미리보기도 회색 막대 세 줄 + 색 동그라미라 다섯 장이 전부 같은 그림이었다 — -사장님은 뭐가 다른지 알 수 없으니 아무거나 골랐다. 사용자 말: "가라 UI 로 되어 있어서 뭐가뭔지 모르겠음". - -**한 일** -- `TemplateItem.look`(`TemplateLook`) 신설: 제목·본문 서체, 모서리, 테두리 두께, 그림자, - 제목 자간·굵기, 섹션 여백. **CSS 에 그대로 들어가는 문자열**로 들고 있다 — 숫자로 두면 - 쓰는 쪽에서 단위를 빠뜨린 곳이 조용히 0 이 된다. -- 업종당 **3개**로 정리: 심플(고딕·둥근·그림자) · 매거진(명조 제목·각짐·그림자 없음·여백 큼) · - 레트로(간판체·2px 테두리·오프셋 그림자·갱지). `templatesFor()` 팩토리 하나가 찍어내고 - **업종은 accent 하나만 바꾼다** — 생김새는 업종이 아니라 취향의 문제다. - `industryData.ts` 537줄 → 237줄. -- 고르는 화면의 미리보기를 **그 템플릿의 서체·모서리·테두리·그림자로 실제로 그린다**(`TemplatePreview`). - -**핵심 수법 — Tailwind 테마 변수를 캔버스 안에서만 덮는다** -`.site-canvas` 에 `--radius-*` · `--shadow-*` 를 내려보내면, 변이 파일 40여 개에 흩어진 -`rounded-*` · `shadow-*` 를 **한 줄도 안 고치고** 전부 템플릿을 따르게 된다. -배수는 Tailwind 기본 비율을 그대로 옮겨, 기준값 0.75rem 이면 지금까지와 픽셀 단위로 같고 0 이면 전부 각진다. - -**밟은 함정** -- `.site-canvas` 는 이미 `--tpl-font-heading/body` 를 **읽고 있었는데 아무도 넣지 않았다.** - 서체가 갈리지 않던 진짜 이유가 이 빠진 고리였다. -- 간판체(Gugi)는 굵기가 한 벌뿐이라 `font-weight:700` 을 주면 브라우저가 가짜 볼드를 씌워 획이 뭉갠다. - → `--tpl-heading-weight` 로 템플릿이 400 을 지정할 수 있게 했다. -- `Noto Serif KR` 을 안 불러오고 있었다. 매거진 제목이 Batang 으로 떨어지는데 맥에는 그 서체가 없다. -- 옛 템플릿 id(`stay-warm-wood` 등)가 DB 에 남아 있어도 `resolveTemplate` 이 첫 템플릿으로 떨어뜨린다. - -**검증** — `tsc·eslint·vite build` 통과(frontend·admin·site). 템플릿 12벌의 look 전량 대조, -옛 id 폴백·레트로만 아이템을 데려오는지 확인. - ---- - -## 2026-09-01 — 붙여넣기 아이템 셋: 가요 다방 · 오늘의 한 장 · 반나절 산책 - -**한 일** -- 섹션 타입 3개 추가(`songs` · `daily` · `course`). 데이터가 수집(fact)이 아니라 - **사장님이 붙여넣은 JSON** 에서 온다 — 새 갈래다. -- `canvas/dataSpec.ts` 신설: 스키마·예시·프롬프트가 한 표에 모인다. 배리에이션 레지스트리와 같은 결이라 - 여기 한 줄을 더하면 캔버스·[콘텐츠] 탭·프롬프트가 동시에 는다. -- `SectionItem.data?: string` 추가. **파싱본이 아니라 원문 문자열**을 담는다. -- [콘텐츠] 탭에 JSON 칸 + [프롬프트 복사] [프롬프트 보기] [예시 넣기] [줄맞춤]. -- 업종 시드 넷 모두에 세 섹션을 **꺼진 채로** 넣었다. - -**왜 이 모양인가** -`gunsan_365_story_db.xlsx`(365행)를 분석한 결과 **고유 주제는 52개고 한 주제가 7회씩 돈다** -(접미사 10개만 회전). 날짜 축으로 카드를 늘어놓으면 이레마다 같은 카드가 돌아온다 — -그래서 묶는 축을 주제로 잡고, 날짜는 일력 한 장에만 썼다. -같은 시트 `DB_Guide` 가 **가사·현대문학 원문 전재를 금지**해서 가요 스키마에 `lyrics` 필드를 -아예 두지 않았다. 없는 칸은 채울 수 없다. - -**밟은 함정** -- **테마 상한 64KB**(`site_service._THEME_MAX_BYTES`). 세 섹션이 각자 JSON 을 채우면 넘고, - 거절은 발행 직전에야 드러난다. → `SECTION_DATA_MAX_CHARS`(12,000자)로 화면에서 먼저 끊는다. -- **`JSON.parse` 오류 메시지가 두 형식이다.** `position N (line L column C)` 형과, 위치 없이 - 깨진 조각만 인용하는 형. 앞의 것만 보면 후자에서 위치를 통째로 잃는다 — 조각을 원문에서 되찾아 센다. -- 파싱은 **절대 throw 하지 않는다.** 편집 중인 JSON 은 늘 깨져 있고, 깨진 순간 캔버스가 죽으면 못 고친다. - -**섹션 관리에 붙인 것** -- 좌측 패널 하단 **[+ 섹션 추가]** → 목록에서 골라 넣는다. 시드에 박아 두지 않는 이유는, - 붙여넣기 아이템은 내용이 없으면 빈 칸이라 아무도 안 쓰는 항목이 늘 붙어 있게 되기 때문이다. -- 나중에 넣은 섹션만 휴지통으로 뺄 수 있다(업종 기본 섹션은 스위치로 끈다). -- **레트로 템플릿**(업종마다 하나: 옛 항구 · 옛 다방 · 노포 · 시간여행)을 고르면 세 아이템이 함께 들어온다. - `TemplateItem.defaultSectionTypes` 가 그 계약이고, **넣기만 하고 빼지 않는다** — - 템플릿을 눌러 보다 넣어 둔 섹션이 사라지면 사장님은 자기가 지웠다고 생각한다. -- 저장 payload 에 `type` 을 실었다. 시드에 없는 섹션은 복원 때 `id` 로 못 찾아 **통째로 버려졌다** - (사장님이 채운 JSON 까지 같이). 이제 `type` 으로 되살린다. - -**아직 안 한 것** -- 발행 사이트(`solution/site`)는 `variantId` 도 `data` 도 아직 안 읽는다. 지금은 빌더 캔버스 전용이다. -- 프롬프트는 상호·주소를 박아 내보낸다(빈칸을 남기면 사장님이 못 채우고 그대로 보낸다). - -**검증** — `tsc --noEmit` · `eslint` · `vite build` 통과(frontend·admin). 세 배리에이션 SSR 렌더 확인, -파서 경계 12건 + 추가·삭제·템플릿·저장복원 왕복 12건 확인. - ---- - -## 2026-09-01 — 설정을 `.env` 하나로 모았다 - -**한 일** -- 백엔드 설정을 toml → `pydantic-settings`(FastAPI 공식 방식)로 옮겼다. -- `config_loader.py` · `config.local.toml.example` · `config.test.toml.example` 삭제. -- `server_configs.py` 107줄 → 26줄. `_apply_*_env_override` 함수 4개 제거. -- 호출부 21개 파일은 안 건드렸다 — 같은 이름을 그대로 내보낸다. - -**왜** -키마다 `if os.environ.get(...)` 를 손으로 나열하는 구조였다. 하나 빠뜨리면 조용히 틀리는데, -실제로 `client_url` 이 빠져 있어 **배포 주소의 API 호출이 전부 CORS 로 막혔다**. -`BaseSettings` 는 필드를 선언하면 환경변수가 자동으로 들어와 이 사고가 구조적으로 안 난다. - -**하는 김에 잡은 잠재 버그** -- `.env` 경로가 세 단계라 `solution/.env`(없는 파일)를 보고 있었다. 백엔드를 `solution/` 아래로 - 옮길 때 안 고쳐진 자리다. toml 이 값을 들고 있어 로컬에서 안 드러났고, 도커는 compose 가 - 환경변수를 직접 넣어 역시 멀쩡했다. toml 을 없앤 지금은 유일한 공급원이라 치명적이었다. -- 환경변수 이름을 `validation_alias` 로 못 박았다. 안 그러면 `port` 필드가 흔한 `PORT` 를 - 주워 먹어 엉뚱한 포트로 뜬다. - -**결과** — 백엔드 설정 파일은 최상위 `.env` 하나뿐이다. → [DECISIONS.md](DECISIONS.md) - ---- +## 2026-09-28 — 숙박 템플릿 다섯 개 추가 (라운드 · 시네마 · 빅타이포 · 부티크 · 일러스트) + +국내 펜션 사이트 46곳을 모바일에서 재 보니 첫 화면 제목 14~24px, 본문 11~14px였다. 기존 템플릿도 +전부 작은 글씨 쪽이라, 토스·카카오뱅크·당근·해든스테이·스테이인터뷰를 390px에서 실측해 뼈대를 새로 만들었다. + +- `site/src/layouts/` 에 `round` `cinema` `bigtype` `boutique` `graphic`. 섹션·탭 네 개는 고택과 같고 + 예약 시트·폼·캐러셀은 고택 부품을 쓴다. 공통 구조 CSS는 `layouts/kit/kit.css`. +- ★ kit.css 에 고택의 글꼴 규칙을 넣지 않는다 — 넣으면 다른 레이아웃의 워드마크가 17.5px로 눌린다(실측). +- `graphic` 은 사진이 거의 없는 집용. 사진 0장이면 기존 발행 게이트("고유 콘텐츠 0건")에 걸린다. + +## 2026-09-28 — 템플릿 정의를 한 파일로 모았다 + +빌더·렌더러·백엔드가 템플릿을 따로 적어 서로 어긋나 있었다(없는 기본 id, 강조색 오타, 섹션 간격 차이). + +- 템플릿 목록은 `shared/src/data/templates.json` 하나. TS와 파이썬이 같은 파일을 읽는다. +- id에서 업종을 뗐다(`stay-retro` → `retro`, 마이그레이션 `0023`, 운영 미적용). +- 모르는 템플릿 id는 저장·미리보기·발행 모두 거절한다. 기본값으로 슬쩍 굽지 않는다. +- 고택(`paper`)을 `/s/stay2` 시안과 같게 다시 만들었다. 하위 페이지는 한 HTML 안의 탭이다. +- 구조와 추가 방법: [TEMPLATES.md](TEMPLATES.md). + +## 2026-09-23 — 개발자용 사이트·유저 관리를 solution 앱에 얹었다 + +admin 앱을 키우기엔 이르다(대표 지시). `UserRole.DEVELOPER` 게이트로 `/ops/sites`·`/ops/users`(읽기 전용). +★ 메뉴 문자열은 사장님 번들에도 실린다(런타임 조건부 렌더) — 데이터는 백엔드 게이트가 막는다. + +## 2026-09-21 ~ 22 — 사장님 에이전트 (빌더 대화창 → 카카오톡 채널) + +설계와 함정은 [AGENT.md](AGENT.md)와 AGENTS.md "에이전트에서 조용히 틀리는 것"이 단일 출처다. + +- 순서: 신원 연결(`owner_kakao_links`) → 도구 레지스트리·런타임·빌더 채팅창 → 카카오 웹훅. + 런타임이 채널을 모르게 만들어 두어, 웹훅을 붙일 때 런타임은 한 줄도 안 바뀌었다. +- 모델에게 맡기지 않은 셋: 확인 등급, 결과 문구, fact key. 확인(SEMI)은 서버가 인자를 다시 검증한다. +- ★ 오픈빌더는 서명이 없다 — 공유 시크릿이 유일한 문이고, 없으면 엔드포인트가 404. +- ★ 카톡 5초 벽: 개발 중 잰 1.3~2.4초는 장난감 프롬프트였고 실사용 첫날 타임아웃이 났다. + 콜백(`useCallback`)으로 즉답 후 따로 보낸다. 오픈빌더 스킬 설정에서 콜백을 켜야 이 경로가 열린다. +- 카톡 대화에는 홈페이지 목록·발행 여부·가게 바꾸기를 LLM 없이 보여 준다(대화가 막혔을 때 늘 통해야 한다). +- 밟은 것: `execute_lambda` 는 람다 반환값을 그대로 준다 — 객체만 돌려주면 언패킹 TypeError 가 나는데 + 라우터가 예외를 삼켜 "지금은 처리할 수 없어요"만 보였다. + +## 2026-09-17 — 미니 블로그: 팀 검수 폐지 · 배정일 · 달력 화면 · 메일 승인 + +상세는 [MINI_BLOG.md](MINI_BLOG.md). + +- 팀 사전검수를 없애고 최종 판단을 사장님에게 넘겼다(팀 단계가 병목이었다). 업장당 하루 한 통. +- `place_posts.scheduled_date` 로 글마다 날짜를 정했다. 빌더는 달력 + 그 위 일주일치 카로셀, 생성 이력 탭. +- 메일 승인 링크는 GET 즉시 승인(프리페치 위험을 알고 사장님이 택했다), 링크는 그날 자정 만료. +- 잡은 버그들: + - 승인이 BUILD 잡에 `owner_user_id` 를 안 실어 **메일 승인이 재발행을 못 하고 있었다**. + - `generate_one` 의 죽은 import 로 "지금 생성하기"가 500. 테스트는 그 함수를 monkeypatch 해서 초록이었다 — + 단위 테스트 초록과 실제로 도는 것은 다르다. + - `scheduled_date` 를 추가하자 값이 NULL인 기존 글 13건이 조회에서 조용히 빠졌다 → 백필. + 새 컬럼 마이그레이션은 "기존 행이 조회에서 빠지는지"부터 본다. + - raw `text()` 로 timestamptz 에 naive datetime 을 넣으면 드라이버 로컬 시간대(KST)로 9시간 밀린다. + - 세션 복구보다 늦게 자동 로그인하면 `RequireAuth` 가 이미 `/login` 으로 튕긴다 → 복구 단계로 옮겼다. + - ORM 객체를 commit 뒤까지 들고 있으면 detached 로 깨진다 → flush 직후 dict 로 뽑는다. + +## 2026-09-16 — 생성·수집 실패를 잡이 견디게 + +- Gemini 호출 실패(429 등)가 온보딩 COPY 잡을 DEAD 로 보내지 않는다. fact 만으로 계속한다 — + 키가 없을 때와 같은 동작. 실측: 사진분석 배치가 분당 쿼터를 다 써서 같은 키의 COPY 가 죽었다. +- 재수집 때 나는 유니크 충돌 로그를 ERROR → WARN(정상 경로인데 오류처럼 보였다). +- 크롤링 실패를 `jobs.result` 에 구조화해 남긴다(`common/collect_diagnostics.py`). +- Teams 웹훅: 플로우 수신자가 예약값(`48:notes`)이라 계속 실패 → 플로우 재생성으로 해결. + +## 2026-09-15 — 발행 버전 전환 · 장애 알림 · 보안 · 서치콘솔 · 생성 진행 복구 + +- **워커가 렌더하고 버전별로 보관, 게이트 통과 뒤 공개 링크를 바꾼다.** 상시 프리렌더를 없앴다. + 배포는 기존 HTML과 목업을 다시 굽지 않는다 → [PUBLISH_VERSION.md](PUBLISH_VERSION.md). +- 장애 알림: `alert_outbox` + 재시도·중복 억제·복구 알림, `/readyz`(DB까지 확인) → [ALERTS.md](ALERTS.md). + ★ 앱·DB 시계가 수십 ms만 어긋나도 방금 넣은 알림이 안 잡혔다 → 비교는 DB 시계(`func.now()`). + ★ HTTP 202 는 워크플로 접수일 뿐 채널 게시 성공이 아니다. +- 운영 번들에서 자동 로그인 자격증명 제거(build arg 삭제 + `import.meta.env.DEV` 가드). + `users.token_version` 으로 비밀번호 변경 시 기존 refresh 토큰을 무효화한다(전에는 7일간 계속 통했다). +- Google 사이트맵 자동 제출·색인 관측 → [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md). +- 콘텐츠 생성 단계 상태를 DB에 기록, URL의 jobId로 새로고침 복구 → [GENERATION_FLOW.md](GENERATION_FLOW.md). + +## 2026-09-14 — SNS 게재 · 엽서 · FAQ 20개 · SEO 키워드 + +- **SNS(스레드) 게재**: 사장님 클릭 → fact로 초안 → 승인 → 사장님 계정으로 게시. 이 레포가 처음으로 + 외부에 쓰고, 남의 자격증명을 보관하고, 되돌릴 수 없는 일을 한다. 설계는 [SOCIAL.md](SOCIAL.md). + 승인은 POST만, 주소가 확정된(`domain`) 사이트만, 사진은 올리지 않는다, 기본 꺼짐. + X는 URL 글 요청당 $0.20이라 뺐다. + ★ `server_default=text("'[]',")` 쉼표가 CREATE TABLE 을 통째로 실패시켰다(테스트 DB에서만 드러난다). +- **엽서 쓰기**를 발행본에 넣었다. ★ 남의 도메인 사진을 캔버스에 그리면 오염돼 저장·공유가 막힌다 + (네이버 CDN은 CORS를 안 준다) → 발행 때 사진을 우리 오리진으로 내려받는 미러로 해결(AGENTS.md). +- **FAQ를 20개까지**: fact로 쓰면 4~8개에서 끝나서, 펜션 공통 질문 카탈로그로 "문의 안내" 답을 채운다. + ★ 공통 답에 값을 적지 않고, 이 답은 JSON-LD·llms.txt·고유 콘텐츠 계수에서 뺀다. +- **SiteOntology 키워드**를 제목·keywords 메타에 싣는다. ★ 추천 10건 중 사실이 아닌 것(마당·복층)이 + 섞여 와서 "모든 낱말이 이 가게 자료에 있어야" 싣는다(10건 → 4건). 모르는 regionId 는 저쪽이 500을 준다. + +## 2026-09-11 — 발행하면 이 숙소의 노래가 생긴다 (가사 Gemini → 작곡 Suno) + +- 발행이 노래를 기다린다(첫 화면에 기능이 빠져 보이지 않게). 실패해도 발행은 막지 않는다. +- 가사는 소개문과 같은 재료로 우리가 쓴다 — Suno에 맡기면 없는 시설을 노래한다. +- ★ Suno 주소는 만료된다 → mp3를 받아 우리 경로로만 내보낸다. 콜백이 아니라 폴링(우리 서버에 닿을 주소가 없다). +- 미리보기 빌드에는 만들지 않는다(유료 호출). + +## 2026-09-10 — 소개문 승인 단계 제거 · 렌더러 이식 · 일력 + +- **생성된 소개문이 영영 안 나가던 것**: 소개문이 수집 확인 화면보다 2분 늦게 도착해 승인할 화면이 없었다. + LLM 출력은 확인된 fact로만 쓰므로 승인 없이 노출값으로 둔다. 사장님이 고친 문장은 LLM이 못 덮는다 + → [DECISIONS.md 7절](DECISIONS.md). + ★ ORM의 timestamptz 기본값 `(now() AT TIME ZONE 'utc')` 가 값을 서버 시간대만큼 미래로 밀어 + 테스트 DB에서 잡이 영영 안 집혔다 → init.sql과 같은 `now()`. +- `/s/stay` 시안이 다른 워크트리의 **커밋 안 된 작업본**에만 있어 렌더러가 갈렸다 → 시안 payload를 현재 + 렌더러로 다시 구워 태그 단위 diff(129줄 → 4줄). 카카오 길찾기가 상호의 쉼표 때문에 목적지를 버리던 것도 고쳤다. +- 일력을 서버 생성에 붙였다. 종류 목록이 두 벌이라 새 종류가 서버에 안 갔고, "한 건이라도 있으면 안 부른다" + 가드가 기존 지역에 새 종류를 영영 막았다 → 없는 종류만 부른다. + +## 2026-09-09 — 지역 이야기 서버 생성 · 예약 목업 + +- 가요·인물·연표·엽서·퀴즈를 지역 단위로 한 번 생성해 같은 지역 사이트가 나눠 쓴다(Perplexity, 출처 필수) + → [DECISIONS.md 6절](DECISIONS.md). +- 예약 흐름 목업(`StayBookingDemo`). 연동 없음 — "마감/잔여"를 지어내지 않고, 시간 후보는 체크인 fact에서만. + ★ 날짜는 브라우저에서 만든다 — 서버에서 구우면 발행일 날짜가 HTML에 박혀 크롤러가 지난 날을 읽는다. + +## 2026-09-08 — 가짜 발행 제거 · `/s` 정본 주소 · 회사(테넌트) 제거 · 네이버 예약 + +- **가짜 발행**: 사업장이 없으면 서버를 안 부르고 [사이트 열기]를 그렸다(주소는 404). 분기를 지우고 + 발행 불가 사유를 모달 안에서 말한다. +- **`/s` 가 빌더 셸을 200으로 주고 있었다** → nginx `location = /s` + `/s/` 301, `absolute_redirect off`. +- **회사 스코프를 걷어냈다** — 스코프 키는 `places.owner_user_id`, 주인은 토큰이 정한다(body로 받지 않는다). + 워커의 `UserInfo.user_id` 는 사업장 주인이어야 한다(랜덤 uuid면 fact가 0건이 된다). +- **예약 버튼이 검색 화면을 열었다** → 플레이스 응답의 `naverBookingUrl` 을 수집해 쓴다. 주소를 조립하지 않는다. +- 내 사이트 목록에 썸네일·주소·시각. 썸네일 주소에 `?v=<버전>` 을 붙여 재발행하면 그림이 바뀌게 했다. + +## 2026-09-07 — 자산 보관과 두 번의 사고 · 로컬 설정 함정 · 숙박 예약 안내 + +- **옛 해시 자산을 30일 남긴다**(대장 `.builds.json`, mtime 을 쓰지 않는다). 배포와 전체 재굽기를 뗐다. +- **사고 1**: 대장이 없는 첫 실행에서 기존 자산이 전부 "대장에 없음"으로 지워져 운영 CSS가 끊겼다. + → 기록이 없으면 입양한다. "기록이 없다"와 "만료됐다"를 같이 묶지 않는다. + 검증은 빈 디렉토리가 아니라 **배포 직전 서버 모습**으로 재현해야 했다. +- **사고 2**: payload 없는 목업(`stay`·`stay2`·`stay3`)의 자산이 지워져 영영 복구 불가가 됐다. + → HTML이 참조하는 자산은 기간과 무관하게 남긴다(`referencedAssets`). 목업의 존재를 AGENTS.md 맨 위에 적었다. +- 사이트맵 lastmod 를 파일 mtime 에서 뗐다 — 배포마다 전 사이트가 "오늘 갱신"으로 통보되어 구글이 필드를 무시하게 된다. +- `.env.example` 함정: 컨테이너 안의 `DB_HOST=127.0.0.1`(워커만 조용히 재시작), 값 뒤 주석이 값이 됨, + API 기본 주소가 크로스 오리진을 만들어 로그인만 실패. → 같은 오리진 기본값, 주석은 윗줄로. +- 숙박 "실시간 예약" 섹션이 전화번호 한 줄이었다(읽는 fact가 숙박 스키마에 없었다) → "예약 안내"로 이름을 바꾸고 + 요금·인원·규정·창구를 모았다. 예약을 처리하지는 않는다. `availability` 는 넣지 않는다. + +## 2026-09-03 — 레포·호스트 교체 · 랜딩 · 로그인 전 검색 · 썸네일 + +- 레포 `Web4ai/o2o-site-AEO`, 호스트 `web4ai.o2osolution.ai`. + ★ `origin` 은 payload에 구워진다 → 재발행이 필요하다. ★ `init.sql` 은 최초 생성 때만 돈다 — + 기존 DB에 컬럼이 없어 로그인이 죽었는데 HTTP는 200이었다. +- 로그인 전 랜딩·요금(월 70만원 한 플랜)·쇼케이스(진짜 발행본만). 상호 검색을 로그인 앞으로(인증 없음, IP 제한). + 업종은 카카오 카테고리로 정하고 LLM을 부르지 않는다. +- 썸네일은 스크린샷이 아니라 대표 사진이다(헤드리스 브라우저는 영구 금지). + +## 2026-09-02 — 가입·구글 로그인 · 내 사이트 홈 · 아이템 · 템플릿 모양(look) + +- 계정 생성 API가 아예 없었다(손으로 INSERT). ★ `GOOGLE_CLIENT_ID` 는 백엔드·프론트가 같아야 하고 + `aud` 대조가 남의 앱 토큰을 막는다. 같은 이메일이라도 계정을 자동으로 잇지 않는다([DECISIONS 1-5](DECISIONS.md)). +- 로그인한 사장님의 홈(`/sites`·`/account`). 위저드에서 사이드바를 뺐다. 삭제 대신 [발행 내리기]만 둔다. +- 붙여넣기 아이템이 발행본에 **하나도 안 나가고 있었다**(`SectionSetting.data` 계약이 없었다). + 직접 쓴 소개문도 같은 이유로 사라졌다(`body`). 계약에 넣고 고유 콘텐츠로 센다. +- `SiteTheme.look`(서체·모서리·그림자 등)이 발행본까지 가게 했다. 웹폰트는 템플릿이 쓰는 것만. +- 계절 추천은 HTML에 전 계절을 굽고 브라우저에서 지금 계절만 보인다(구운 시점의 계절이 박히지 않게). + +## 2026-09-01 — 설정을 `.env` 하나로 + +toml → `pydantic-settings`. 키마다 손으로 덮던 구조에서 `client_url` 이 빠져 배포 주소 API가 전부 CORS로 막혔다. +★ `.env` 경로가 없는 파일을 보고 있었다. 환경변수 이름은 `validation_alias` 로 못 박는다(`PORT` 를 주워 먹는다). ## 2026-08-31 — 킹서버 최초 배포 -**한 일** -- `~/data2/o2o-web4ai` 에 배포. DB(`web4ai_db`) 생성 + `init.sql` 적용. -- 컴포즈 포트를 전부 `.env` 변수로 뽑았다. 로컬 기본값은 그대로다. -- `deploy.sh` · `log.sh` 추가. - -**왜 포트를 뽑았나** -킹서버는 `:80` 을 호스트 nginx 가 이미 물고 있다. 사내망에 열려 있는 건 30xxx 대역뿐이라 -그 안에서 자리를 잡아야 했다. → [SERVERS.md](SERVERS.md) - -**밟은 함정** -- `PUBLIC_API_BASE_URL` 은 **브라우저가** 부르는 주소다. `localhost` 로 두면 화면은 뜨고 - API 만 죽는다 — 콘솔을 열기 전엔 안 보인다. -- 내부 화면의 "빌더 열기" 가 `VITE_SOLUTION_URL` 미주입으로 죽은 링크였다. 로컬에서는 - 기본값이 맞는 주소라 서버에 올리기 전까지 드러나지 않았다. -- `deploy.sh api` 는 worker·api-admin 도 함께 갈아끼운다. 셋이 이미지 한 벌을 나눠 쓰는데 - 하나만 바꾸면 옛 코드로 도는 컨테이너가 남고, `ps` 로는 셋 다 살아 있어 구분이 안 된다. - -**남은 것** — `w4ai.o2o.kr` DNS + 앞단(59.14.81.3) 포워딩. 서버에 sudo 가 없어 인프라 몫이다. +포트를 전부 `.env` 로 뺐다(`:80` 은 호스트 nginx가 쓴다) → [SERVERS.md](SERVERS.md). +★ `PUBLIC_API_BASE_URL` 은 브라우저가 부르는 주소다. ★ `deploy.sh api` 는 worker·api-admin 도 같이 갈아 끼운다. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index bd4fd35..7d61075 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -104,8 +104,7 @@ ## 9. 아직 안 정한 것 정해지는 대로 이 절에서 위로 올린다. 코드로 미리 풀지 않는다. -★ **개발 착수 전에 확정해야 할 결정 목록은 -[DEVELOPMENT_DIRECTION.md P0](DEVELOPMENT_DIRECTION.md)** 가 단일 출처다 — 여기 복사하지 않는다. +★ **보류 중인 결정은 [DECISIONS.md](DECISIONS.md) 1절**이 단일 출처다 — 여기 복사하지 않는다. 아래는 그중 **제품 정의**에 해당하는 것만 남긴다. - 사업 성공 지표 (7절) diff --git a/docs/RENDERING.md b/docs/RENDERING.md new file mode 100644 index 0000000..af8e42a --- /dev/null +++ b/docs/RENDERING.md @@ -0,0 +1,157 @@ +# 렌더링 한눈에 보기 + +사이트가 화면에 그려지는 경우는 세 가지다. 셋 다 그리는 코드는 `solution/site/src/App.tsx` 하나이고, +**누가 언제 그리느냐**만 다르다. + +| 경우 | 누가 그리나 | 입력 | +|---|---|---| +| 정적 사이트 | 워커가 미리 구운 HTML → 브라우저가 이어받음 | HTML 안에 심어 둔 payload | +| 미리보기 | 브라우저가 처음부터 | API 가 그때그때 만든 payload | +| 발행 | 워커가 Node 렌더러를 돌려 HTML 을 굽는다 | DB → payload 파일 | + +경로는 레포 루트 기준이다. `site/` 는 `solution/site/`, `backend/` 는 `solution/backend/` 를 줄인 것이다. + +--- + +## 1. 정적 사이트 — 손님·크롤러가 `/s/` 를 받을 때 + +```mermaid +flowchart TD + A["손님 · 크롤러
GET /s/<slug>"] --> B["nginx
location ^~ /s/"] + B --> C["out/s/<slug>
(심볼릭 링크)"] + C --> D["out/versions/<slug>/<ver>/index.html"] + D -->|크롤러는 여기까지| E["HTML · JSON-LD · meta"] + D --> F["브라우저가 /assets/index-해시.js · .css 를 받음"] + F --> G["entry-client.tsx
window.__SITE_PAYLOAD__ 있음"] + G --> H["hydrateRoot(App)
버튼·달력 등 동작이 붙는다"] + D --> I["사진 /s/<slug>/img/*
노래 /s/<slug>/*.mp3"] +``` + +| 단계 | 하는 일 | 파일 | +|---|---|---| +| 요청 받기 | `/s/` 는 구운 파일을 그대로 준다. `/s` 는 목록, `/s/` 는 `/s` 로 301 | `nginx/site.conf.example` (`location = /s`, `location ^~ /s/`) | +| 공개 버전 찾기 | `out/s/` 는 지금 공개 중인 버전 폴더를 가리키는 링크다 | `site/scripts/prerender.ts` `publishVersion` | +| HTML | 본문·``(title·canonical·JSON-LD)·심어 둔 payload 가 한 파일에 있다 | `out/versions///index.html` | +| 번들 | 해시 이름의 JS·CSS. 1년 캐시 | `nginx/site.conf.example` `location ^~ /assets/` → `out/assets/` | +| 이어받기 | 심어 둔 payload 로 같은 화면을 다시 만들어 마크업에 동작을 붙인다 | `site/src/entry-client.tsx` (`hydrateRoot`) | +| 그리기 | 템플릿의 레이아웃을 고르고 섹션을 순서대로 그린다 | `site/src/App.tsx` → `site/src/pages/SectionList.tsx` | + +검색엔진이 읽는 건 구운 HTML 이다. 렌더러를 고쳐도 이미 구운 HTML 은 사장님이 다시 발행하기 전까지 그대로다. + +--- + +## 2. 미리보기 — 빌더 iframe `/preview?placeId=…` + +```mermaid +flowchart TD + A["빌더에서 템플릿·색·섹션 저장
POST …/site/template · …/site/theme"] --> B["onSiteThemeSaved 신호"] + B --> C["SitePreview.tsx
iframe 다시 로드"] + C --> D["GET /preview?placeId=…
nginx location = /preview"] + D --> E["out/preview/index.html
빈 껍데기 + 번들"] + E --> F["entry-client.tsx renderPreview"] + F --> G["GET /v1/place/{id}/site/preview"] + G --> H["SiteService.preview_payload
build_snapshot → prepare_site_payload"] + H --> F + F --> I["themeVars · 폰트 로드"] + I --> J["createRoot(App)"] + J --> K["postMessage o2o:preview-painted"] + K --> L["빌더가 스피너를 걷는다
(12초 상한)"] +``` + +| 단계 | 하는 일 | 파일 | +|---|---|---| +| 다시 그릴 때를 안다 | 저장이 끝나면 iframe 을 새로 고친다. 보던 스크롤 위치는 지킨다 | `solution/frontend/src/features/builder/SitePreview.tsx`, `solution/frontend/src/features/publish/siteTheme.ts` `onSiteThemeSaved` | +| 껍데기 받기 | 본문이 빈 HTML. `noindex` 가 붙어 있다 | `nginx/site.conf.example` `location = /preview` → `out/preview/index.html` (`prerender.ts` `writePreviewShell`) | +| payload 받기 | 로그인 토큰을 붙여 API 를 부른다 | `site/src/entry-client.tsx` `renderPreview` | +| payload 만들기 | 발행과 같은 함수로 만든다. 버전도 파일도 만들지 않는다 | `backend/router/v1/site/site.py` `site_preview` → `backend/services/site_service.py` `preview_payload` → `services/snapshot.py` `build_snapshot` → `services/site_payload.py` `prepare_site_payload` | +| 템플릿 확인 | 모르는 id 면 API 가 422, 화면은 에러 문구로 멈춘다 | `backend/common/template_catalog.py`, `solution/shared/src/lib/catalog.ts` `templateOf` | +| 그리기 | 색 변수·폰트를 먼저 넣고 처음부터 그린다 | `entry-client.tsx` (`themeVars`, `fontHref` ← `site/src/seo/head.ts`), `App.tsx` | +| 완료 알림 | 두 프레임 뒤 부모 창에 알린다. 빌더는 출처와 iframe 을 확인한다 | `entry-client.tsx` `signalPreviewPainted`, `SitePreview.tsx` `PAINT_TIMEOUT_MS` | + +미리보기는 사진을 내려받지 않는다. 원래 주소를 그대로 쓴다. + +--- + +## 3. 발행 — 무엇을 읽고 무엇을 쓰나 + +```mermaid +flowchart TD + A["사장님 '발행하기'
POST /v1/place/{id}/site/build"] --> B["SiteService.start_build
jobs 에 BUILD"] + B --> C["워커 worker/handlers.py
build_service.run_build"] + C --> D["build_snapshot
DB 값 모으기 · site_versions 행 추가"] + D --> E{"1차 게이트
상호·업종·사실 확인 · 템플릿 id"} + E -->|실패| X["버전 FAILED · 발행 로그"] + E --> F["emit_payload
payloads/<slug>.json"] + F --> G["render_service.render_site
node prerender.js --stage-only"] + G --> H["mirrorMedia → prerenderSite
out/versions/<slug>/<ver>/"] + H --> I["보고서
payloads/.status/<slug>.json"] + I --> J{"2차 게이트
publish_gate.evaluate"} + J -->|실패| X + J --> K["render_service.activate_site
node prerender.js --activate=slug:ver"] + K --> L["out/s/<slug> 링크 전환
루트 sitemap · robots · llms 갱신"] + L --> M["Azure 업로드 · 썸네일 · IndexNow"] + M --> N["DB 기록
버전 BUILT · sites PUBLISHED · 발행 로그"] +``` + +| 단계 | 하는 일 | 파일 | +|---|---|---| +| 잡 넣기 | 검증 안 된 사업장은 막는다. 같은 사업장 BUILD 는 겹치지 않는다 | `backend/router/v1/site/site.py` `start_build` → `services/site_service.py` `start_build` | +| 잡 집기 | BUILD 잡을 `run_build` 로 넘긴다 | `backend/worker/handlers.py` | +| 스냅샷 | DB 값을 한 벌로 모아 `site_versions.snapshot` 에 박제한다 | `services/snapshot.py` `build_snapshot`, `services/build_service.py` `run_build` | +| 1차 게이트 | 상호명·업종·사실 확인 여부, 템플릿 id | `services/publish_gate.py` `check_facts_verified`, `common/template_catalog.py` `resolve_template_id` | +| payload | JSON 으로 쓴다. 임시 파일에 쓰고 이름을 바꾼다 | `services/site_payload.py` `emit_payload` → `write_payload` | +| 굽기 | Node 를 직접 실행한다. 파일 잠금으로 한 번에 하나만 돈다 | `services/render_service.py` `render_site` (`.render.lock`) | +| 렌더 | 공개 금지 값 걸러내기 → 사진 내려받기 → HTML·JSON-LD·llms.txt → 대조 | `site/scripts/prerender.ts` `sanitizePayloadForPublish` · `mirrorMedia` · `prerenderSite` · `verifyJsonLd` | +| 2차 게이트 | 보고서의 대조 결과·고유 콘텐츠 건수로 판정 | `services/publish_gate.py` `evaluate`, `services/render_report.py` | +| 공개 전환 | 검증된 버전인지 보고서로 다시 보고 링크를 바꾼다 | `render_service.activate_site` → `prerender.ts` `publishVersion` · `writeRootMachineFiles` | +| 바깥 알리기 | 설정된 경우만 돈다 | `services/azure_static.py` `publish`, `services/site_thumbnail.py` `store`, `services/indexnow.py` `submit` | +| DB 기록 | 버전·사이트·사업장 상태와 발행 로그를 남긴다 | `services/build_service.py` `run_build` · `_log` | + +### 입력 + +| 무엇 | 어디서 | 읽는 쪽 | +|---|---|---| +| DB 값 | `place_facts` `place_units` `place_faqs` `place_photos` `place_songs` `place_posts` `place_reviews` `place_social_posts` `area_contents` `site_sections` `sites` | `services/snapshot.py` `build_snapshot` | +| 템플릿 목록 | `solution/shared/src/data/templates.json` | 백엔드 `common/template_catalog.py`, 렌더러 `shared/src/lib/catalog.ts` | +| payload | `site/payloads/.json` (`SITE_PAYLOAD_DIR`) | `prerender.ts` `loadOne` — `schemaVersion` 1 · 슬러그 · 버전을 본다 | +| 번들 목록 | `site/dist/client/.vite/manifest.json` | `prerender.ts` `readAssets` — 엔트리 JS·CSS 파일명 | +| 번들 파일 | `site/dist/client/assets/`, `site/public/fonts/` | `prerender.ts` `writeSharedAssets` | +| 사진 | `payload.media[].url` 이 가리키는 바깥 주소 | `prerender.ts` `mirrorMedia` (15초 · 8MB) | +| 노래 | `site/songs/*.mp3` | `prerender.ts` `copySongs` | + +### 출력 + +| 무엇 | 어디에 | 누가 쓰나 | +|---|---|---| +| payload | `site/payloads/.json` | `site_payload.py` `write_payload` | +| 렌더 보고서 | `site/payloads/.status/.json` | `prerender.ts` `writeReport` (백엔드가 `render_report.py` 로 읽는다) | +| HTML | `out/versions///index.html` — JSON-LD 는 따로 파일이 없고 `` 안 `` 한 줄이 스크립트 태그를 닫아 버린다 — 그 뒤 내용이 마크업으로 새고, - * 최악의 경우 임의 스크립트가 된다. U+2028/2029 는 JS 문법상 줄바꿈이라 함께 막는다. - */ +/** payload 를 HTML 안에 심을 수 있게 직렬화한다. */ function serializePayload(payload: SitePayload): string { return JSON.stringify(payload) .replace(/ = { @@ -361,32 +254,13 @@ const MEDIA_EXT: Record = { 'image/gif': '.gif', }; -/** - * 사진을 **우리 오리진으로 옮긴다.** - * - * ★ 왜 (2026-09-15, 실측) - * 발행본 사진은 수집한 자리(`*.pstatic.net` · `tong.visitkorea.or.kr`)를 그대로 가리켰다. - * 그 호스트들은 `Access-Control-Allow-Origin` 을 주지 않는다 — 그래서 그 사진을 캔버스에 - * 그리면 **캔버스가 오염돼 파일로 못 뽑는다**(브라우저 정책). 엽서 쓰기의 저장·공유가 - * 모든 발행 사이트에서 막혀 있었고("이 사진은 다른 사이트에 올라와 있어…"), - * 화면에는 미리보기만 남았다. 클라이언트에서는 넘을 방법이 없다 — CORS 없는 `fetch` 도 - * 같은 벽에 막힌다. **같은 오리진에 파일이 있어야** 풀린다. - * ★ 덤이 아니라 같이 딸려 오는 것: 남의 CDN 이 핫링크를 끊거나 주소를 바꾸면 사진이 - * 통째로 사라지는데, 옮겨 놓으면 그 일이 우리 사이트를 건드리지 못한다. - * ★ **재게시 권리(DECISIONS 1-2)의 결론을 앞당기지 않는다.** 화면에 이미 싣고 있는 것만 - * 같은 자리로 옮기는 것이고, `originUrl` · `sourceType` 은 그대로 남는다 — - * "불가" 로 결론 나면 `sourceType = CRAWL` 을 발행에서 빼는 그 대응이 그대로 먹는다. - * ★ 실패는 조용히 넘긴다. 못 받은 사진은 **원래 주소를 그대로 쓴다** — 사진이 사라지는 것보다 - * 공유가 막힌 채로 보이는 쪽이 낫다. - */ +/** 사진을 **우리 오리진으로 옮긴다.** */ async function mirrorMedia(payload: SitePayload, siteDir: string) { const items = (payload.media ?? []).filter((item) => /^https?:\/\//i.test(item.url ?? '')); if (items.length === 0) return; const dir = join(siteDir, MEDIA_DIR); - // ★ 절대 주소로 바꾼다. 이 주소는 `` 뿐 아니라 og:image · JSON-LD 의 image 로도 - // 나가는데, 그 둘은 절대 주소여야 한다(상대 주소를 주면 크롤러마다 다르게 읽는다). - // 수집 주소도 절대였으니 바뀌는 것은 호스트뿐이다. + // 절대 주소로 바꾼다. const publicBase = joinUrl(payload.site.origin, payload.site.basePath); mkdirSync(dir, {recursive: true}); @@ -395,8 +269,7 @@ async function mirrorMedia(payload: SitePayload, siteDir: string) { for (const item of items) { const origin = item.url; - // 파일명은 **주소의 해시**다. 같은 사진이 두 사이트에 있어도 각자 폴더라 부딪히지 않고, - // 주소가 그대로면 이름도 그대로라 다시 구워도 내려받지 않는다. + // 파일명은 **주소의 해시**다. const stem = createHash('sha1').update(origin).digest('hex').slice(0, 16); const hit = readdirSync(dir).find((name) => name.startsWith(`${stem}.`)); if (hit) { @@ -428,8 +301,7 @@ async function mirrorMedia(payload: SitePayload, siteDir: string) { } } - // 지난 발행의 사진은 치운다 — 노래(copySongs)와 같은 이유다. 안 치우면 사장님이 사진을 - // 바꿀 때마다 쌓이고, Azure 발행 때 그대로 같이 올라간다. + // 지난 발행의 사진은 치운다 — 노래(copySongs)와 같은 이유다. for (const name of readdirSync(dir)) { if (!wanted.has(name)) rmSync(join(dir, name), {force: true}); } @@ -450,18 +322,7 @@ function isLegacyFlatSite(publicDir: string): boolean { } } -/** - * 공개 주소(`out/s/`)를 이 버전으로 **원자적으로** 돌린다. - * - * ★ 왜 심볼릭 링크인가 — 임시 이름으로 링크를 만들고 `renameSync` 로 덮어씌운다. POSIX 에서 - * 같은 디렉토리 안의 rename 은 원자적이다(파일시스템 저널이 "옛 링크"와 "새 링크" 사이의 - * 중간 상태를 방문자에게 보여주지 않는다). 그래서 굽는 동안 크래시가 나거나 게이트가 - * 막아도 **공개 주소는 절대 절반만 바뀐 상태가 되지 않는다** — 직전 버전이거나 이번 - * 버전이거나 둘 중 하나다. - * ★ 첫 발행이 아니고 `out/s/` 가 아직 **일반 디렉토리**(이 코드 이전 산출물)면, 지우지 - * 않고 `out/versions//legacy/` 로 옮겨 붙인다 — 그 자리가 유일한 사본인 사이트가 - * 있을 수 있어서다(옛 굽기는 이력을 안 남겼다). 옮긴 뒤에는 일반 심볼릭 링크 전환과 같다. - */ +/** 공개 주소(`out/s/`)를 이 버전으로 **원자적으로** 돌린다. */ function publishVersion(outRoot: string, slug: string, version: number): void { const publicDir = join(outRoot, SITE_DIR, slug); const target = versionDir(outRoot, slug, version); @@ -476,8 +337,7 @@ function publishVersion(outRoot: string, slug: string, version: number): void { renameSync(publicDir, legacy); console.log(` (마이그레이션) 옛 산출물을 versions/${slug}/${LEGACY_VERSION}/ 로 보존`); } else { - // legacy 자리가 이미 있다(재시도 등) — 지금 것은 legacy 보다 못 믿을 이유가 없지만 - // 둘을 합치지 않는다. 그대로 둔 채 아래에서 심볼릭 링크로 덮어쓴다(원자적 교체). + // legacy 자리가 이미 있다(재시도 등) — 지금 것은 legacy 보다 못 믿을 이유가 없지만 둘을 합치지 않는다. throw new Error('기존 사이트와 legacy가 함께 있어 자동 전환을 중단합니다'); } } @@ -490,16 +350,7 @@ function publishVersion(outRoot: string, slug: string, version: number): void { renameSync(tmp, publicDir); } -/** - * 오래된 버전 디렉토리를 지운다. **지금 공개된 버전과 `legacy` 는 절대 지우지 않는다.** - * - * ★ 보관 정책 — `out/assets` 의 자산 보관(ASSET_RETENTION_DAYS)과 같은 사고방식이다: - * 최근 N개(ROLLBACK_MIN_VERSIONS) 또는 최근 D일(ROLLBACK_RETENTION_DAYS) 중 **더 오래 - * 남기는 쪽**을 따른다. 둘 다 지난 버전만 지운다 — 롤백 대상은 이 안에서 고른다. - * ★ 지워진 버전도 DB(site_versions.snapshot) 에는 남는다. 그 버전으로 롤백하면 - * `rollback_service.py` 가 snapshot 으로 **다시 굽는다** — 디스크에서 지운 것이지 - * 되돌릴 수 없게 잃은 것이 아니다(자산과 달리 재생성 비용이 있을 뿐이다). - */ +/** 오래된 버전 디렉토리를 지운다. */ const ROLLBACK_MIN_VERSIONS = 5; const ROLLBACK_RETENTION_DAYS = 30; @@ -538,7 +389,6 @@ function assetPlan(payload: SitePayload, outRoot: string, siteDir: string) { const basePath = payload.site.basePath.replace(/\/+$/, ''); const suffix = `/${SITE_DIR}/${payload.site.slug}`; // out/ 이 도메인 루트가 아닌 곳에 마운트돼도 맞도록 basePath 에서 역산한다. - // ('/s/joy' → '' · '/sites/s/joy' → '/sites') const rootPrefix = basePath.endsWith(suffix) ? basePath.slice(0, -suffix.length) : ''; const shared = basePath !== ''; @@ -546,23 +396,12 @@ function assetPlan(payload: SitePayload, outRoot: string, siteDir: string) { shared, /** 자산을 실제로 복사해 넣을 디렉토리. */ dir: shared ? outRoot : siteDir, - /** HTML 이 참조할 접두사. 두 경우 모두 루트 절대경로가 된다. */ + /** HTML 이 참조할 접두사. */ base: `${rootPrefix}/assets`, }; } -/** - * 이 숙소의 노래 파일을 사이트 디렉토리로 옮겨 놓는다. - * - * ★ 왜 백엔드가 직접 out/ 에 쓰지 않나 - * 백엔드는 발행물 디렉토리를 모른다 — payload JSON 을 약속된 자리에 떨구는 것이 경계다 - * (ARCHITECTURE 1절). 노래도 같은 약속을 쓴다: 백엔드는 `site/songs/<파일>` 에 두고, - * 굽는 쪽인 여기가 사이트 안으로 복사한다. 그래야 Azure 발행(`azure_static.publish`)이 - * 사이트 디렉토리를 통째로 올릴 때 노래도 함께 올라간다. - * - * ★ 없으면 조용히 넘어간다. 곡은 발행보다 2~3분 늦게 완성되므로 "아직 없음" 이 정상이고, - * 그때 payload 에 songs 가 비어 있어 화면도 플레이어를 안 그린다. - */ +/** 이 숙소의 노래 파일을 사이트 디렉토리로 옮겨 놓는다. */ function copySongs(payload: SitePayload, siteDir: string) { const wanted = new Set(); @@ -580,16 +419,7 @@ function copySongs(payload: SitePayload, siteDir: string) { copyFileSync(from, to); } - /* - * 지난 발행의 곡은 치운다. - * - * ★ 발행할 때마다 새 곡을 만들고 파일명은 song_id 라, 치우지 않으면 발행 횟수만큼 1MB 짜리 - * 파일이 사이트 디렉토리에 쌓인다. 그리고 그것들은 Azure 발행 때 **함께 올라간다** — - * 아무도 듣지 않는 옛 곡이 계속 쌓이는 종류의 낭비다. - * ★ 자산(out/assets)과 달리 보관 기간을 두지 않는다. 번들은 크롤러가 나중에 렌더할 때 - * 필요하지만(AGENTS.md), 노래는 그 페이지에서 버튼을 눌러야 나는 것이라 옛 HTML 이 - * 가리킬 일이 없다 — payload 에 실린 곡 하나만 남기면 된다. - */ + /* 지난 발행의 곡은 치운다. */ if (!existsSync(siteDir)) return; for (const entry of readdirSync(siteDir, {withFileTypes: true})) { if (!entry.isFile() || !entry.name.endsWith('.mp3')) continue; @@ -604,23 +434,15 @@ function prerenderSite( assets: ReturnType, referenced: Set, ) { - // ★ 여기서 한 번 깎고, 그 뒤로는 깎인 payload 만 쓴다 — - // 서버 렌더 · JSON-LD · llms.txt · HTML 에 심는 블롭이 전부 같은 객체를 본다. - // (블롭만 원본으로 두면 미검증 fact 가 HTML 소스로 새고, AI 크롤러는 그걸 읽는다.) + // 여기서 한 번 깎고, 그 뒤로는 깎인 payload 만 쓴다 — 서버 렌더 · JSON-LD · llms.txt · HTML 에 심는 블롭이 전부 같은 객체를 본다. const payload = sanitizePayloadForPublish(input); - // ★ 이번 버전 전용 디렉토리에 굽는다(`out/s/` 가 아니다) — publishVersion 주석 참조. - // 실패해도 이 디렉토리만 지저분해질 뿐 공개 주소는 안 건드린다. + // 이번 버전 전용 디렉토리에 굽는다(`out/s/` 가 아니다) — publishVersion 주석 참조. const siteDir = versionDir(outRoot, payload.site.slug, payload.site.version); const plan = assetPlan(payload, outRoot, siteDir); - /** 브라우저가 자산을 찾아갈 접두사. 파일이 놓인 자리와 같아야 한다. */ + /** 브라우저가 자산을 찾아갈 접두사. */ const assetBase = plan.base; - /** - * ★ 먼저 메모리에 굽고, 검증을 통과한 뒤에야 파일로 쓴다. - * 렌더하면서 바로 쓰면 검증에 걸린 페이지가 이미 디스크에 나가 있게 된다 — - * 그 순간 방문자와 크롤러가 그걸 읽는다. 게이트가 있으나 마나가 된다. - * 실패하면 아무것도 쓰지 않으므로 **직전 버전이 그대로 서비스된다**(빈 사이트가 되지 않는다). - */ + /** 먼저 메모리에 굽고, 검증을 통과한 뒤에야 파일로 쓴다. */ const mismatches: string[] = []; // 실패하더라도 보고서에 실어야 하므로 먼저 센다. const uniqueContentCount = countUniqueContent(payload); @@ -641,9 +463,7 @@ function prerenderSite( ' ', head, ' ', - /* ★ class="site" 는 장식이 아니다 — 시안 토큰·유틸(shared/styles/site.css)이 이 클래스 - 안에서만 산다. 빼면 유동 타이포와 .shell·.h2 가 통째로 죽어 글자 크기가 본문으로 떨어진다. - 빌더 캔버스는 같은 규칙을 `.site-canvas` 로 받는다. */ + /* class="site" 는 장식이 아니다 — 시안 토큰·유틸(shared/styles/site.css)이 이 클래스 안에서만 산다. */ ' ', `
${appHtml}
`, ` `, @@ -652,7 +472,7 @@ function prerenderSite( '', ].join('\n'); - /** ★ 절대규칙 3 — 나갈 바로 그 HTML 에 대고 대조한다. */ + /** 절대규칙 3 — 나갈 바로 그 HTML 에 대고 대조한다. */ for (const problem of verifyJsonLd(html, jsonld)) mismatches.push(problem); for (const problem of verifyGeo(jsonld, payload.place.latitude, payload.place.longitude)) { mismatches.push(problem); @@ -662,48 +482,30 @@ function prerenderSite( throw new VerifyError(mismatches, uniqueContentCount); } - /** - * ★ 절대규칙 2 — 이 가게에만 있는 콘텐츠가 0건이면 굽지 않는다. - * - * 같은 템플릿으로 대량 생성한 사이트는 스팸 판정을 받고, 판정되면 사이트가 통째로 무의미해진다. - * 이 검사도 **렌더러 안**에 있어야 한다 — 백엔드가 나중에 거부하더라도 그 전에 이미 - * 페이지가 디스크에 나가 있으면 크롤러가 그걸 읽는다. - */ + /** 절대규칙 2 — 이 가게에만 있는 콘텐츠가 0건이면 굽지 않는다. */ if (uniqueContentCount <= 0) { throw new NoUniqueContentError(uniqueContentCount); } writeFile(siteDir, 'index.html', html); - /** - * 기계용 파일은 `llms.txt` 하나만 남는다. - * - * ★ 사이트별 `sitemap.xml` 을 없앴다 — 한 장짜리 사이트의 사이트맵은 URL 이 하나뿐이라, - * 사이트가 1,000개면 URL 한 줄짜리 파일이 1,000개 생긴다. 루트 사이트맵 하나에 - * 전부 담는다(사이트맵 하나에 URL 50,000개까지 들어간다). - * - * ★ 사이트별 `robots.txt` 도 없앴다 — `/s//robots.txt` 는 **아무도 읽지 않는다**. - * 크롤러는 오리진 루트에서만 읽는다(RFC 9309). - */ + /** 기계용 파일은 `llms.txt` 하나만 남는다. */ writeFile(siteDir, 'llms.txt', renderLlmsTxt(payload)); copySongs(payload, siteDir); - // 하이드레이션용 번들. 공용 호스트면 out/ 루트 한 벌을 공유하므로 여기서는 아무것도 안 한다 - // (main 이 사이트를 굽기 전에 한 번 깔아 둔다). 커스텀 도메인일 때만 사이트 안에 복사한다. + // 하이드레이션용 번들. if (!plan.shared) { writeSharedAssets(plan.dir, referenced); } - // 이전 구현이 사이트마다 복사해 둔 자산이 남아 있으면 지운다 — 공용으로 바뀐 뒤에는 - // 아무도 참조하지 않는 죽은 파일이고, 사이트 수만큼 디스크를 계속 먹는다. + // 이전 구현이 사이트마다 복사해 둔 자산이 남아 있으면 지운다 — 공용으로 바뀐 뒤에는 아무도 참조하지 않는 죽은 파일이고, 사이트 수만큼 디스크를 계속 먹는다. if (plan.shared) { rmSync(join(siteDir, 'assets'), {recursive: true, force: true}); rmSync(join(siteDir, 'fonts'), {recursive: true, force: true}); } - // 보고서용 — 실제로 나간 JSON-LD 를 그대로 담는다. 백엔드가 이걸 - // site_versions.jsonld(파이썬 빌더 산출물)와 대조해 두 렌더러의 드리프트를 잡는다. + // 보고서용 — 실제로 나간 JSON-LD 를 그대로 담는다. return { siteDir, payload, @@ -712,32 +514,19 @@ function prerenderSite( }; } -/** - * 이 가게에만 있는 콘텐츠 건수 — 백엔드 `count_unique_content` 와 **같은 규칙**이다. - * - * ★ 두 숫자가 다르면 게이트가 통과시킨 근거와 실제 페이지가 어긋났다는 뜻이다. - * 백엔드가 보고서를 받아 대조한다. 규칙을 바꿀 땐 반드시 양쪽을 같이 고친다. - * (backend/services/builder/render.py 의 MIN_UNIQUE_TEXT · count_unique_content) - */ +/** 이 가게에만 있는 콘텐츠 건수 — 백엔드 `count_unique_content` 와 **같은 규칙**이다. */ const MIN_UNIQUE_TEXT = 8; function countUniqueContent(payload: SitePayload): number { - // socialPosts는 우리 출력이다. 세면 고유 콘텐츠 0건인 사이트가 자기 소개글로 게이트를 우회한다. + // socialPosts는 우리 출력이다. const long = (value: unknown) => String(value ?? '').trim().length >= MIN_UNIQUE_TEXT; let count = 0; // 소개 섹션의 직접 입력 본문은 실제 AboutSection 에 표시되는 가게 고유 문장이다. - // ★ enabled 를 함께 본다. 꺼서 페이지에 없는 문장까지 세면 빈 페이지가 게이트를 통과한다. const intro = payload.theme.sections.find((section) => section.id === 'intro'); if (intro?.enabled && long(intro.body)) count += 1; - /** - * 붙여넣기 아이템(가요·일력·승차권·인물…)의 항목도 이 가게에만 있는 문장이다. - * - * ★ 안 세면 "곡을 여덟 개 채웠는데 고유 콘텐츠 0건으로 발행이 막힌다"가 된다 — - * 소개 본문(intro.body)이 계약에 없던 시절과 같은 구멍이다. - * ★ 긴 문장이 한 줄이라도 있는 항목만 센다. 제목·연도만 있는 줄은 가게를 구분하지 못한다. - */ + /** 붙여넣기 아이템(가요·일력·승차권·인물…)의 항목도 이 가게에만 있는 문장이다. */ const hasLongText = (value: unknown): boolean => { if (typeof value === 'string') return long(value); if (Array.isArray(value)) return value.some(hasLongText); @@ -768,16 +557,7 @@ function countUniqueContent(payload: SitePayload): number { return count; } -/** - * 렌더 결과 보고서. payload 파일 옆(`.status/.json`)에 쓴다. - * - * ★ 왜 필요한가 - * 지금까지 프리렌더는 성공하든 실패하든 아무것도 남기지 않았다. 빌드가 깨지면 - * DB 에는 "발행됨"으로 남고 페이지는 없는 상태가 되는데, 아무도 그걸 모른다. - * 백엔드에 마운트된 유일한 디렉토리가 payload 디렉토리라 보고서도 그 안에 쓴다. - * - * ★ 임시파일 → rename. 백엔드가 반쯤 쓰인 JSON 을 읽지 않게 한다. - */ +/** 렌더 결과 보고서. */ interface RenderReport { schemaVersion: 1; slug: string; @@ -790,7 +570,7 @@ interface RenderReport { bundle: string; uniqueContentCount: number | null; jsonld: unknown[] | null; - /** ★ 절대규칙 3 위반 목록. 비어야 발행 가능하다 — 백엔드 게이트가 이걸 본다. */ + /** 절대규칙 3 위반 목록. */ mismatches: string[]; error: string | null; } @@ -804,35 +584,16 @@ 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). 같은 번들로 다시 구우면 갱신된다. */ + /** 이 번들을 마지막으로 깐 시각(ISO). */ at: string; /** assets/ 기준 상대 경로. */ files: string[]; @@ -849,32 +610,15 @@ 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일)으로는 못 막는다 — 기간이 지나면 - * 똑같은 일이 난다. **참조가 살아 있는 한 남긴다** 가 유일하게 맞는 규칙이다. - * - * ★ 사이트를 굽기 전에 부른다. 그래야 이번에 다시 굽지 않는 사이트의 옛 참조가 잡힌다. - * ★ 공개 자리(`out/s/`)뿐 아니라 `out/versions//<옛 버전>/` 도 훑는다 — 롤백 - * 대상으로 보관 중인 버전(pruneOldVersions 가 아직 안 지운 것)이 가리키는 번들도 살려야 - * 링크만 돌려 롤백할 때 CSS·JS 가 404 가 안 난다. - */ +/** 발행본이 **지금 실제로 참조하고 있는** 자산. */ function isDirLike(parent: string, entry: {name: string; isDirectory(): boolean; isSymbolicLink(): boolean}): boolean { - // `out/s/` 는 심볼릭 링크다(publishVersion). readdirSync 의 Dirent 는 링크 자체의 - // 타입만 보고하므로, 대상이 디렉토리인지는 statSync 로 한 번 더 확인해야 한다. + // `out/s/` 는 심볼릭 링크다(publishVersion). if (entry.isDirectory()) return true; if (!entry.isSymbolicLink()) return false; try { return statSync(join(parent, entry.name)).isDirectory(); } catch { - return false; // 끊어진 링크 — 대상이 지워졌다. + return false; } } @@ -918,18 +662,12 @@ function readAssetLedger(assetsDir: string): AssetBuild[] { }; return Array.isArray(parsed.builds) ? parsed.builds : []; } catch { - // 없거나 깨졌으면 빈 대장으로 시작한다. 디스크에 있던 파일은 pruneAssets 가 - // "처음 본 것" 으로 입양하므로 지워지지 않는다 — 그 ★ 주석이 이 실패의 근거다. + // 없거나 깨졌으면 빈 대장으로 시작한다. return []; } } -/** - * 보관 기간이 지난 옛 해시 파일만 지운다. - * - * ★ 발행할 때마다 이 함수가 돈다(사이트 하나만 구울 때도). 번들이 그대로면 대장에 줄이 - * 늘지 않고 맨 앞 줄의 시각만 갱신된다 — 안 그러면 발행 횟수만큼 대장이 자란다. - */ +/** 보관 기간이 지난 옛 해시 파일만 지운다. */ function pruneAssets(assetsDir: string, current: string[], referenced: Set) { const signature = (files: string[]) => [...files].sort().join('\n'); const now = new Date().toISOString(); @@ -940,15 +678,7 @@ function pruneAssets(assetsDir: string, current: string[], referenced: Set build.files)); const adopted = listRelativeFiles(assetsDir).filter( (file) => file !== ASSET_LEDGER && !recorded.has(file), @@ -966,7 +696,7 @@ function pruneAssets(assetsDir: string, current: string[], referenced: Set) { const assetsSrc = join(CLIENT_DIR, 'assets'); if (existsSync(assetsSrc)) { @@ -1002,34 +726,8 @@ function writeSharedAssets(destRoot: string, referenced: Set) { } } -/** - * 오리진 루트의 `robots.txt` 와 사이트맵 인덱스. - * - * ★ 왜 필요한가 - * 크롤러는 robots.txt 를 **오리진 루트에서만** 읽는다(RFC 9309). 발행 사이트는 - * `/s//` 아래라, 지금까지 구워 온 사이트별 robots.txt 는 한 번도 읽힌 적이 없다 — - * AI 크롤러 명시 허용도, `Sitemap:` 지시도 전달되지 않았다. 사이트맵은 만들어 두고 - * 그 존재를 알릴 방법이 없었으니 크롤러가 사이트를 찾아올 경로 자체가 없었다. - * - * ★ 왜 이번 실행에 온 payload 가 아니라 출력 디렉토리를 훑는가 - * 발행은 **바뀐 사이트 하나만** 굽는다(scripts/watch-payloads.mjs). 이번 실행분만 인덱스에 - * 담으면 나머지 사이트가 인덱스에서 사라진다. 디스크에 실제로 존재하는 발행본이 곧 정답이다. - */ -/** - * 빌더 미리보기가 iframe 으로 띄우는 CSR 셸. - * - * ★ 왜 iframe 인가 (2026-09-09) - * 빌더 안에 발행본 컴포넌트를 **직접** 그려 봤는데, 색·서체·섹션 구성이 같아져도 - * **레이아웃 폭이 어긋났다.** 미디어 쿼리는 창 폭을 보는데 미리보기의 실제 사이트 폭은 - * 그 안의 프레임(max-w-5xl)이기 때문이다. 실측(1400px 창 · 1024px 프레임): - * festival 2560px → 6027px, guide 1168 → 2168, location 586 → 1135. - * 내용(글자 수)은 완전히 같은데 그리드 컬럼 수만 달라 두 배씩 길어졌다. - * iframe 은 자체 뷰포트를 가져서 미디어 쿼리가 발행본과 **정확히 같은 폭**을 본다. - * PC/태블릿/모바일 전환도 iframe 폭만 바꾸면 그대로 맞는다. - * - * ★ payload 는 셸이 아니라 브라우저가 가져온다(`entry-client`). 굽는 시점에는 - * 어느 사업장을 미리 볼지 모르고, 미리보기는 **발행 전 최신 값**을 봐야 한다. - */ +/** 오리진 루트의 `robots.txt` 와 사이트맵 인덱스. */ +/** 빌더 미리보기가 iframe 으로 띄우는 CSR 셸. */ function writePreviewShell(outRoot: string, assets: {script: string; css: string[]}) { const lines = [ '', @@ -1037,7 +735,7 @@ function writePreviewShell(outRoot: string, assets: {script: string; css: string ' ', ' ', ' ', - // ★ 미리보기는 색인 대상이 아니다. 발행 전 값이라 검색에 걸리면 안 된다. + // 미리보기는 색인 대상이 아니다. ' ', ' 미리보기', ...assets.css.map((file) => ` `), @@ -1054,20 +752,7 @@ function writePreviewShell(outRoot: string, assets: {script: string; css: string console.log(' ✓ 빌더 미리보기 셸 (/preview)'); } -/** - * 이미 구워져 있는 사이트에서 오리진을 알아낸다. 한 장도 없으면 빈 문자열. - * - * ★ payload 를 읽지 않는 경로(`--seed-assets` 기동)에서 루트 색인 파일을 다시 쓰려면 - * 오리진이 필요한데, 그 값은 payload 에만 있다. 디스크에 있는 페이지가 자기 canonical 로 - * 선언해 둔 것을 쓴다(readBakedOrigin 주석). - * ★ **가장 최근에 구워진 페이지**의 오리진을 쓴다 — 처음 만난 것을 쓰면(readdirSync 순서는 - * 보장이 없다) 호스트를 옮긴 뒤 한 번도 재발행 안 한 옛 사이트(목업 포함) 하나가 걸릴 때 - * 전체 sitemap.xml 이 죽은 옛 호스트로 통째로 구워진다. 실측(2026-09-18): SITE_PUBLIC_HOST - * 를 web4ai.o2osolution.ai 로 옮긴 지 오래인데, solution-worker 가 재시작(배포)될 때마다 - * 이 함수가 옛 호스트(w4ai.o2o.kr)로 구운 사이트 하나를 집어 사이트맵 전체가 죽은 주소로 - * 덮였다 — 오늘 새로 발행한 사이트까지 사이트맵에서 옛 호스트로 나갔다. 갓 구운 페이지일수록 - * 지금 SITE_PUBLIC_HOST 를 반영했을 확률이 높다. - */ +/** 이미 구워져 있는 사이트에서 오리진을 알아낸다. */ function findBakedOrigin(outRoot: string): string { const sitesDir = join(outRoot, SITE_DIR); if (!existsSync(sitesDir)) return ''; @@ -1089,41 +774,31 @@ function writeRootMachineFiles(outRoot: string, origin: string) { if (!existsSync(sitesDir)) return; const sites: DirectoryEntry[] = readdirSync(sitesDir, {withFileTypes: true}) - // `out/s/` 는 이제 버전 디렉토리를 가리키는 심볼릭 링크다(publishVersion) — 링크 - // 자체는 isDirectory() 가 false 이므로 isDirLike 로 대상까지 확인해야 목록에서 안 빠진다. + // `out/s/` 는 이제 버전 디렉토리를 가리키는 심볼릭 링크다(publishVersion) — 링크 자체는 isDirectory() 가 false 이므로 isDirLike 로 대상까지 확인해야 목록에서 안 빠진다. .filter((entry) => isDirLike(sitesDir, entry)) .map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')})) - // index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. 사이트맵에 넣지 않는다. + // index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. .filter((entry) => existsSync(entry.file)) // 제목·lastmod·noindex 가 같은 HTML 에서 나온다 — 파일은 한 번만 읽는다. .map((entry) => ({...entry, html: readFileSync(entry.file, 'utf-8')})) - // ★ noindex 를 선언한 페이지(목업·백업)는 싣지 않는다 — readBakedNoindex 주석 참조. + // noindex 를 선언한 페이지(목업·백업)는 싣지 않는다 — readBakedNoindex 주석 참조. .filter((entry) => !readBakedNoindex(entry.html)) .map(({html, ...entry}) => { 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 주석 참조). + // mtime 으로 떨어지는 건 dateModified 메타가 없던 시절의 산출물뿐이다. lastmod: readBakedLastmod(html) ?? statSync(entry.file).mtime.toISOString(), }; }) .sort((a, b) => a.loc.localeCompare(b.loc)); - // ★ `/s` 목록 페이지. 크롤러가 발행본에 닿는 두 번째 경로다 — - // 사이트맵만 있을 때 서치콘솔은 "참조 페이지: 감지된 페이지 없음" 이라고 답했다. - // ★ 끝 슬래시를 붙이지 않는다 — 슬러그 페이지(`/s/`)와 같은 형태여야 한다. - // nginx 가 `location = /s` 로 이 파일을 직접 주고 `/s/` 는 여기로 301 한다 - // (nginx/site.conf). 그 블록이 없으면 `/s` 는 빌더 SPA 셸을 200 으로 내준다. + // `/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'); @@ -1133,19 +808,11 @@ function writeRootMachineFiles(outRoot: string, origin: string) { writeIndexNowKey(outRoot); } -/** - * IndexNow 키 파일 — `https:///.txt` 에 키 문자열만 들어 있다. - * - * ★ 이게 없으면 백엔드의 통보가 403 으로 거절된다. 검색엔진은 통보를 받으면 - * 이 URL 을 열어 같은 키가 있는지 보고, 그걸로 "이 호스트를 제어하는 쪽이 보냈다"를 확인한다. - * 비밀이 아니다 — 공개되어야 작동하는 값이다. - * - * ★ 백엔드와 **같은 env 를 본다**. 키를 두 군데 적으면 어긋나는 날 통보가 조용히 다 막힌다. - */ +/** IndexNow 키 파일 — `https:///.txt` 에 키 문자열만 들어 있다. */ function writeIndexNowKey(outRoot: string) { const key = (process.env.INDEXNOW_KEY ?? '').trim(); if (!key) return; - // 규격: 8~128자, 영문·숫자·하이픈. 어긋나면 굽지 않는다(잘못된 파일이 있으면 원인 찾기가 더 어렵다). + // 규격: 8~128자, 영문·숫자·하이픈. if (!/^[A-Za-z0-9-]{8,128}$/.test(key)) { console.warn(' ! INDEXNOW_KEY 형식이 규격(8~128자 영문·숫자·하이픈)에 맞지 않아 건너뛴다'); return; @@ -1168,24 +835,12 @@ async function main() { return; } - /* - * ★ 자산만 시딩(dev 편의) — **굽지 않는다.** 개발 컨테이너(solution-frontend --profile dev)가 - * 기동할 때 `out/assets` · `out/fonts` · `public/` 을 채워 두는 용도다. HTML 을 하나도 - * 건드리지 않으므로 예전 refreshBakedAssets 와 달리 공개된 사이트의 내용·주소 참조에 - * 아무 영향이 없다 — 운영 워커(render_service.py)는 이 모드를 쓰지 않는다(BUILD 잡마다 - * 실제 payload 로 호출하고, 그 경로가 매번 writeSharedAssets 를 이미 돈다). - */ + /* 자산만 시딩(dev 편의) — **굽지 않는다.** 개발 컨테이너(solution-frontend --profile dev)가 기동할 때 `out/assets` · `out/fonts` · `public/` 을 채워 두는 용도다. */ if (args.seedAssets) { console.log(`[prerender] 공용 자산만 시딩 → ${args.out}`); writeSharedAssets(args.out, referencedAssets(args.out)); writePreviewShell(args.out, assets); - /* - * ★ 루트 색인(sitemap.xml · llms.txt · `/s` 목록)은 여기서도 다시 쓴다. - * 이 파일들은 **디스크의 `out/s/` 를 훑어** 만들어지므로 내용은 손대지 않는다 — - * 페이지 HTML 은 그대로고, 사라진 사이트가 목록에서 빠지고 남아 있는 사이트는 그대로다. - * 이게 없던 동안 내려간 사이트가 사이트맵에 계속 남았다: 페이지는 404 인데 구글은 - * 그 주소를 계속 크롤하고, `/s` 목록에는 열리지 않는 카드가 남았다(실측 2026-09-15). - */ + /* 루트 색인(sitemap.xml · llms.txt · `/s` 목록)은 여기서도 다시 쓴다. */ const bakedOrigin = findBakedOrigin(args.out); if (bakedOrigin) writeRootMachineFiles(args.out, bakedOrigin); console.log('[prerender] 완료'); @@ -1196,18 +851,17 @@ async function main() { console.log(`[prerender] 사이트 ${loaded.length}개 → ${args.out}`); - // ★ 굽기 **전에** 참조를 훑는다. 이번에 다시 굽지 않는 사이트(payload 가 없는 목업 포함)가 - // 무엇을 가리키고 있는지는 지금 디스크에 있는 HTML 만 안다. + // 굽기 **전에** 참조를 훑는다. const referenced = referencedAssets(args.out); - // ★ 공용 자산은 사이트를 굽기 전에 딱 한 번 깐다. 사이트마다 복사하던 걸 여기로 뺐다. + // 공용 자산은 사이트를 굽기 전에 딱 한 번 깐다. writeSharedAssets(args.out, referenced); let failed = 0; - /** 루트 기계용 파일을 쓸 오리진. 이 호스트의 사이트는 전부 같은 오리진을 쓴다. */ + /** 루트 기계용 파일을 쓸 오리진. */ let origin = ''; - /** 보고서용 슬러그. payload 를 못 읽었으면 파일명에서 얻는다(백엔드가 `.json` 으로 쓴다). */ + /** 보고서용 슬러그. */ const slugOf = (entry: LoadedPayload) => entry.payload?.site?.slug ?? (entry.file ? basename(entry.file, '.json') : ''); @@ -1238,13 +892,13 @@ async function main() { }; for (const entry of loaded) { - // 읽기·검증 단계에서 이미 실패한 건 굽지 않는다. 다른 사이트는 계속 간다. + // 읽기·검증 단계에서 이미 실패한 건 굽지 않는다. if (entry.error || !entry.payload) { fail(entry, entry.error ?? 'payload 를 읽지 못했습니다'); continue; } - // ★ 목업이 쓰는 슬러그는 굽지 않는다 — 구우면 손으로 만든 유일본을 덮는다(PROTECTED_SLUGS). + // 목업이 쓰는 슬러그는 굽지 않는다 — 구우면 손으로 만든 유일본을 덮는다(PROTECTED_SLUGS). if (PROTECTED_SLUGS.has(entry.payload.site.slug)) { fail( entry, @@ -1254,12 +908,10 @@ async function main() { } try { - // ★ 굽기 **전에** 사진을 우리 자리로 옮긴다 — 렌더·JSON-LD·og:image 가 전부 같은 - // payload 를 보므로, 여기서 주소를 바꿔 놓아야 한 벌로 맞는다. - // 이번 버전 전용 디렉토리에 내려받는다 — 공개 주소는 아직 안 건드린다. + // 굽기 **전에** 사진을 우리 자리로 옮긴다 — 렌더·JSON-LD·og:image 가 전부 같은 payload 를 보므로, 여기서 주소를 바꿔 놓아야 한 벌로 맞는다. const stagingDir = versionDir(args.out, entry.payload.site.slug, entry.payload.site.version); const savedReportPath = join(stagingDir, '.render-report.json'); - // 성공한 버전은 불변이다. 재시도·롤백이 옛 HTML과 번들을 다시 쓰지 않는다. + // 성공한 버전은 불변이다. if (existsSync(savedReportPath) && existsSync(join(stagingDir, 'index.html'))) { const saved = JSON.parse(readFileSync(savedReportPath, 'utf8')); if (!saved.ok || saved.siteId !== entry.payload.site.siteId) throw new Error('저장 버전 불일치'); @@ -1275,8 +927,7 @@ async function main() { const payload = result.payload; origin = origin || payload.site.origin; - // ★ publish=false(미리보기·재빌드)면 여기서 끝난다 — `out/versions/…` 에는 남지만 - // `out/s/` 는 손대지 않는다. BUILD 잡이 publish=false 로 넘긴 경우가 이 길이다. + // publish=false(미리보기·재빌드)면 여기서 끝난다 — `out/versions/…` 에는 남지만 `out/s/` 는 손대지 않는다. if (payload.site.publish && !args.stageOnly) { publishVersion(args.out, payload.site.slug, payload.site.version); pruneOldVersions(args.out, payload.site.slug, payload.site.version); @@ -1297,7 +948,7 @@ async function main() { siteVersion: payload.site.version, ok: true, renderedAt: new Date().toISOString(), - // 사이트당 한 장. 보고서 스키마는 백엔드가 읽으므로 필드는 남긴다. + // 사이트당 한 장. routes: 1, bundle: assets.script, uniqueContentCount: result.uniqueContentCount, @@ -1309,10 +960,7 @@ async function main() { writeReport(entry.file, report); } } catch (ex) { - // ★ 한 사이트가 깨졌다고 나머지를 못 굽게 두지 않는다. 실패는 보고서로 남긴다 — - // 조용히 넘어가면 "발행했는데 페이지가 없다"가 다시 반복된다. - // ★ 계수를 못 잰 실패(디스크·번들·payload 파손)는 null 로 남긴다. 0 으로 적으면 - // 백엔드가 "고유 콘텐츠 0건" 으로 읽어 또 엉뚱한 사유를 붙인다. + // 계수를 못 잰 실패(디스크·번들·payload 파손)는 null 로 남긴다. const counted = ex instanceof VerifyError || ex instanceof NoUniqueContentError ? ex : null; fail( entry, @@ -1323,8 +971,7 @@ async function main() { } } - // ★ 실패한 사이트가 있어도 루트 파일은 갱신한다 — 성공한 사이트까지 색인에서 빠질 이유가 없다. - // (인덱스는 디스크를 훑으므로 실패한 사이트는 애초에 들어가지 않는다.) + // 실패한 사이트가 있어도 루트 파일은 갱신한다 — 성공한 사이트까지 색인에서 빠질 이유가 없다. writePreviewShell(args.out, assets); if (origin) writeRootMachineFiles(args.out, origin); diff --git a/solution/site/scripts/serve-sites.mjs b/solution/site/scripts/serve-sites.mjs index 50e264d..e8390b6 100644 --- a/solution/site/scripts/serve-sites.mjs +++ b/solution/site/scripts/serve-sites.mjs @@ -1,13 +1,4 @@ -/** - * 발행 사이트 정적 서버. - * - * ★ python -m http.server 를 쓰지 않는 이유 - * `/s/mmg` (끝 슬래시 없음)로 들어오면 404 를 준다. 사장님이 주소창에 치는 형태가 그건데 - * 열리지 않으면 "발행했는데 안 나온다"가 된다. 여기서는 디렉토리면 index.html 로 넘긴다. - * - * ★ 이 서버는 개발용이다. 운영에서는 out/ 을 nginx·CDN 이 그대로 서빙한다 — - * 그때도 같은 규칙(디렉토리 → index.html)만 맞추면 된다. - */ +/** 발행 사이트 정적 서버. */ import {createReadStream, existsSync, statSync} from 'node:fs'; import {createServer} from 'node:http'; import {dirname, extname, join, normalize, resolve} from 'node:path'; diff --git a/solution/site/scripts/watch-payloads.mjs b/solution/site/scripts/watch-payloads.mjs index 7b86c0d..888dc62 100644 --- a/solution/site/scripts/watch-payloads.mjs +++ b/solution/site/scripts/watch-payloads.mjs @@ -1,29 +1,4 @@ -/** - * payload 감시 → 자동 프리렌더. - * - * ★ 왜 필요한가 - * 발행 잡은 payload JSON 까지만 만든다(backend/services/site_payload). 그 뒤 HTML 로 굽는 - * 단계가 수동이라, 사장님이 [발행]을 눌러도 사이트가 없었다 — [사이트 열기] 가 404 였다. - * 검색 → 크롤링 → 발행 → **사이트 이동** 이 끊기는 유일한 자리가 여기다. - * - * ★ 왜 백엔드 워커가 직접 안 굽나 - * 굽는 데 Node 와 이 프로젝트의 의존성이 필요하다. 파이썬 컨테이너에 Node 를 넣으면 - * 백엔드 이미지가 프론트 빌드 도구를 떠안는다. 대신 payload 디렉토리를 사이에 두고 - * 따로 도는 프로세스가 읽는다 — 백엔드는 파일만 쓰고, 여기는 파일만 본다. - * - * ★ 바뀐 사이트만 굽는다 - * 예전에는 payload 디렉토리를 통째로 넘겨서, 한 명이 발행하면 발행된 사이트 전부를 - * 다시 구웠다(게다가 매번 vite 클라이언트 번들까지 새로 만들었다). 사이트가 늘면 - * 그대로 못 쓴다. 지금은 mtime 이 바뀐 payload 만 골라 넘기고, 번들은 기동 때 한 번만 만든다. - * - * ★ 실패를 삼키지 않는다 - * 프리렌더가 깨지면 DB 에는 "발행됨"인데 페이지는 없는 상태가 된다. 실패한 payload 는 - * 백오프를 두고 다시 시도하고, 소진되면 경고로 남긴다. 결과 보고서는 프리렌더가 - * payloads/.status/.json 에 쓴다(백엔드가 그걸 읽는다). - * - * 실행: npm run watch (개발) - * node scripts/watch-payloads.mjs --once (한 번만) - */ +/** payload 감시 → 자동 프리렌더. */ import {spawn} from 'node:child_process'; import {existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync} from 'node:fs'; import {dirname, join, resolve} from 'node:path'; @@ -36,11 +11,11 @@ const PRERENDER_JS = join(ROOT, 'dist', 'prerender', 'prerender.js'); const SITES_DIR = join(ROOT, 'out', 's'); const ONCE = process.argv.includes('--once'); -/** 폴링 간격. 발행은 분 단위 작업이라 2초 지연은 문제가 되지 않는다. */ +/** 폴링 간격. */ const POLL_MS = 2000; -/** 실패한 payload 재시도 횟수. 소진되면 경고만 남기고 다음 변경을 기다린다. */ +/** 실패한 payload 재시도 횟수. */ const MAX_ATTEMPTS = 3; -/** 재시도 백오프(회차별 ms). 디스크 순단·부분 기록 같은 일시 실패를 흡수한다. */ +/** 재시도 백오프(회차별 ms). */ const BACKOFF_MS = [5000, 20000]; function log(msg) { @@ -51,12 +26,7 @@ function warn(msg) { console.error(`[watch ${new Date().toTimeString().slice(0, 8)}] ${msg}`); } -/** - * 자식 프로세스 하나를 돌리고 `{code, stderr}` 를 돌려준다. - * - * ★ stderr 를 흘려보내면서 동시에 모은다. 화면에는 지금까지처럼 그대로 나가야 하고 - * (개발자가 보는 것), 실패 보고서에는 사유가 실려야 한다(백엔드가 읽는 것). - */ +/** 자식 프로세스 하나를 돌리고 `{code, stderr}` 를 돌려준다. */ function run(command, args, label) { return new Promise((done) => { log(`${label} 시작`); @@ -64,7 +34,7 @@ function run(command, args, label) { let stderr = ''; child.stderr.on('data', (chunk) => { process.stderr.write(chunk); - // 사유는 앞부분에 나온다. 스택 전체를 들고 있을 이유가 없다. + // 사유는 앞부분에 나온다. if (stderr.length < 4000) stderr += chunk.toString(); }); child.on('close', (code) => { @@ -79,23 +49,7 @@ function run(command, args, label) { }); } -/** - * 프리렌더가 **자기 보고서를 쓰지도 못하고 죽었을 때** 대신 실패를 남긴다. - * - * ★ 왜 필요한가 - * 프리렌더는 사이트별 실패를 스스로 `.status/.json` 에 적는다. 그런데 프로세스가 - * 사이트를 하나도 돌기 전에 죽으면(의존성 누락·번들 오류) 보고서가 아예 안 생긴다. - * 그러면 백엔드 BUILD 잡은 180초를 꼬박 기다린 뒤 "결과를 못 받았다"로만 실패한다 — - * 진짜 사유(예: Cannot find package 'embla-carousel-react')는 컨테이너 로그에만 남고 - * 사장님 화면에도, 발행 기록에도 안 나타난다. 실제로 그 상태로 47분간 모든 사이트의 - * 재발행이 조용히 죽어 있었다. - * - * ★ 재시도가 소진된 뒤에만 쓴다. 첫 실패에 바로 쓰면 백엔드가 그 보고서를 집어가서 - * 재시도가 성공해도 이미 늦는다 — 백오프(5s+20s)는 180초 안에 끝나므로 여유가 있다. - * - * ★ siteVersion 을 payload 에서 읽어 싣는다. 백엔드는 **버전이 맞는 보고서만** 받으므로 - * (backend/services/render_report.wait_for) 버전이 없으면 이 보고서는 무시된다. - */ +/** 프리렌더가 **자기 보고서를 쓰지도 못하고 죽었을 때** 대신 실패를 남긴다. */ function writeFailureReport(file, error) { let payload; try { @@ -128,52 +82,30 @@ function writeFailureReport(file, error) { } } -/** - * 클라이언트 번들 + 프리렌더 번들을 만든다. **기동 때 한 번만** 부른다. - * - * ★ payload 가 바뀌었다고 번들을 다시 만들 이유가 없다. 데이터만 바뀌었고 코드는 그대로다. - * 코드가 바뀌면 컨테이너가 다시 뜨고, 그때 여기를 지난다. - */ -/** - * 클라이언트 번들 → 프리렌더 번들. 앞이 실패하면 뒤는 돌리지 않는다. - * - * ★ run() 은 `{code, stderr}` 를 돌려준다. 예전에는 이 값을 숫자로 알고 `code === 0` 으로 - * 비교했는데, 객체는 0 과 절대 같지 않아서 **프리렌더 번들이 한 번도 실행되지 않았다.** - * 기동 때마다 "번들 빌드 실패" 로 끝났고, 그래서 DEPLOY.md 가 약속한 '기동 시 전체 - * 재굽기'가 실제로는 일어나지 않았다. - */ +/** 클라이언트 번들 + 프리렌더 번들을 만든다. */ +/** 클라이언트 번들 → 프리렌더 번들. */ async function buildBundles() { const client = await run('npm', ['run', 'build:client'], '클라이언트 번들'); if (client.code !== 0) return client; return run('npm', ['run', 'build:prerender'], '프리렌더 번들'); } -/** payload 파일들을 굽는다. 빈 배열이면 아무것도 하지 않는다. - * ★ 호출부가 `{code, stderr}` 를 구조분해하므로 빈 경우에도 같은 모양을 돌려준다 — - * 숫자 0 을 돌려주면 code 가 undefined 가 되어 성공이 실패로 읽힌다. */ +/** payload 파일들을 굽는다. */ function prerender(files, reason) { if (files.length === 0) return Promise.resolve({code: 0, stderr: ''}); const label = `프리렌더 ${files.length}개 — ${reason}`; return run('node', [PRERENDER_JS, ...files.map((file) => `--payload=${file}`)], label); } -/** - * 기동 때 공용 자산(out/assets · out/fonts · public/)만 채워 둔다. **굽지 않는다.** - * - * ★ 발행 버전 시스템(2026-09-15)으로 바뀌면서 "이미 구워진 HTML 의 자산 주소만 갈아 끼우는" - * 예전 방식(refreshBakedAssets)은 없어졌다 — 버전마다 자기 디렉토리에 굽고 공개 심볼릭 - * 링크(`out/s/`)는 발행할 때만 돈다(prerender.ts publishVersion). 그래서 기동 시 - * 할 일은 아직 하나도 안 구워진 payload 를 굽는 것과, 그 전에 공용 자산을 한 번 깔아 - * 두는 것뿐이다 — HTML 은 하나도 건드리지 않는다. - */ +/** 기동 때 공용 자산(out/assets · out/fonts · public/)만 채워 둔다. */ function seedAssets() { return run('node', [PRERENDER_JS, '--seed-assets'], '공용 자산 시딩 — 기동'); } // ── 큐 ──────────────────────────────────────────────────────────────────── -/** 굽기를 기다리는 payload 경로. 굽는 동안 들어온 변경은 여기에 쌓였다가 이어서 돈다. */ +/** 굽기를 기다리는 payload 경로. */ const pending = new Set(); -/** payload 경로 → 지금까지 실패한 횟수. 성공하면 지운다. */ +/** payload 경로 → 지금까지 실패한 횟수. */ const attempts = new Map(); let running = false; @@ -196,16 +128,13 @@ async function drain() { continue; } - // ★ 배치가 실패하면 어느 사이트가 깨졌는지는 보고서(.status/.json)에 남는다. - // 여기서는 배치 전체를 재시도한다 — 프리렌더는 사이트별로 실패를 격리하므로 - // 이미 성공한 사이트를 다시 구워도 결과는 같다(멱등). + // 배치가 실패하면 어느 사이트가 깨졌는지는 보고서(.status/.json)에 남는다. for (const file of batch) { const tried = (attempts.get(file) ?? 0) + 1; attempts.set(file, tried); if (tried >= MAX_ATTEMPTS) { warn(`${basenameOf(file)} — ${tried}회 실패, 재시도를 멈춥니다.`); - // 프리렌더가 자기 보고서를 못 남기고 죽었을 수 있다 — 그러면 백엔드가 180초를 - // 헛기다린 뒤 사유 없이 실패한다. 여기서 사유를 실어 남긴다(writeFailureReport 주석 참조). + // 프리렌더가 자기 보고서를 못 남기고 죽었을 수 있다 — 그러면 백엔드가 180초를 헛기다린 뒤 사유 없이 실패한다. writeFailureReport(file, stderr.trim()); continue; } @@ -223,12 +152,12 @@ function basenameOf(file) { return file.slice(file.lastIndexOf('/') + 1); } -/** 그 payload 가 이미 구워져 있나. 파일명이 슬러그다(백엔드가 `.json` 으로 쓴다). */ +/** 그 payload 가 이미 구워져 있나. */ function bakedIndexOf(file) { return join(SITES_DIR, basenameOf(file).replace(/\.json$/, ''), 'index.html'); } -/** payload 디렉토리의 *.json 목록. 백엔드가 rename 전에 쓰는 임시파일(.tmp)은 건너뛴다. */ +/** payload 디렉토리의 *.json 목록. */ function listPayloads() { if (!existsSync(PAYLOAD_DIR)) return []; return readdirSync(PAYLOAD_DIR) @@ -247,14 +176,12 @@ async function main() { return; } - // ★ 기동 시 전부 굽지 않는다(seedAssets 주석). 공용 자산만 깔아 두고, 내용은 - // 사장님이 다시 발행할 때(또는 아래 "아직 안 구워진 것") 새 렌더러로 구워진다. + // 기동 시 전부 굽지 않는다(seedAssets 주석). const all = listPayloads(); const seen = new Map(all.map((file) => [file, statSync(file).mtimeMs])); await seedAssets(); - // 아직 한 번도 안 구워진 payload 는 굽는다 — 감시가 꺼져 있는 동안 발행됐거나 볼륨이 - // 비어 있던 경우다. 사이트가 아예 없는 것과 "옛 내용으로 서 있는 것" 은 다른 문제다. + // 아직 한 번도 안 구워진 payload 는 굽는다 — 감시가 꺼져 있는 동안 발행됐거나 볼륨이 비어 있던 경우다. const unbuilt = all.filter((file) => !existsSync(bakedIndexOf(file))); await prerender(unbuilt, '아직 안 구워진 것'); @@ -262,18 +189,14 @@ async function main() { log(`감시 중: ${PAYLOAD_DIR} (바뀐 payload 만 굽습니다)`); - /** - * ★ fs.watch 를 쓰지 않는다. Docker 볼륨(bind mount)을 통해 들어온 변경은 macOS 에서 - * inotify/FSEvents 이벤트가 오지 않는 경우가 있다 — 발행해도 아무 일이 안 일어난다. - * 폴링은 느리지만 확실하다. - */ + /** fs.watch 를 쓰지 않는다. */ setInterval(() => { for (const file of listPayloads()) { let mtime; try { mtime = statSync(file).mtimeMs; } catch { - continue; // 폴링과 rename 이 겹친 순간. 다음 틱에 다시 본다. + continue; // 폴링과 rename 이 겹친 순간. } if (seen.get(file) === mtime) continue; seen.set(file, mtime); diff --git a/solution/site/src/App.tsx b/solution/site/src/App.tsx index fa998cb..98859ab 100644 --- a/solution/site/src/App.tsx +++ b/solution/site/src/App.tsx @@ -1,68 +1,20 @@ -import type {ReactNode} from 'react'; -import type {SitePayload} from '@o2o/shared'; -import {MobileTabBar, SiteFooter, SiteHeader} from '@site/sections'; +import {templateOf, type SitePayload} from '@o2o/shared'; import {SiteProvider} from '@site/lib/site-context'; -import {layoutOf} from '@site/lib/layout'; -import {Shell as ReservationShell} from '@site/layouts/reservation/Shell'; -import {Shell as OasiShell} from '@site/layouts/oasi/Shell'; -import {Shell as StudioShell} from '@site/layouts/studio/Shell'; -import {Shell as PastelShell} from '@site/layouts/pastel/Shell'; -import {Shell as EditorialShell} from '@site/layouts/editorial/Shell'; -import {Shell as PaperShell} from '@site/layouts/paper/Shell'; -import {HomePage} from '@site/pages'; +import {LayoutProvider} from '@site/lib/layout'; +import {LAYOUTS} from '@site/layouts'; +import {SectionList} from '@site/pages'; -/** - * 발행 사이트 — **한 장짜리다.** - * - * ★ 왜 라우터를 쓰지 않나 (2026-08-31 결정) - * 소상공인 사이트는 원래 내용이 적다. 그걸 홈·객실·주변·오시는길·FAQ 로 쪼개면 - * 페이지마다 얇아지고, 검색엔진은 그런 페이지를 색인에서 버린다("Crawled – currently - * not indexed"). 한 장에 모으면 알찬 페이지 하나가 된다. - * 덤으로 `basename` 을 맞추던 문제(SSR 은 /faq, 하이드레이션 후엔 /s/mmg/faq)가 - * 통째로 사라진다 — 섹션 이동은 전부 앵커(#faq)다. - * - * 섹션 순서·표시 여부는 HomePage 가 theme.sections 로 정한다. - */ export function App({payload}: {payload: SitePayload}) { - /* - * ★ 껍데기(상단·본문 폭·하단)를 템플릿이 정한다. - * 여기 한 벌로 두면 색만 다른 사이트가 나온다 — 안이 갈리려면 뼈대가 갈려야 한다. - */ - const layout = layoutOf(payload.theme.templateId); - const Shell = - layout === 'reservation' - ? ReservationShell - : layout === 'oasi' - ? OasiShell - : layout === 'studio' - ? StudioShell - : layout === 'pastel' - ? PastelShell - : layout === 'editorial' - ? EditorialShell - : layout === 'paper' - ? PaperShell - : DefaultShell; + const layout = LAYOUTS[templateOf(payload.theme.templateId).layout]; + const {Frame} = layout; return ( - - - + + + + + ); } - -/** 기본 껍데기 — 지금까지 발행된 사이트가 쓰는 그것. 건드리지 않는다. */ -function DefaultShell({children}: {children: ReactNode}) { - return ( -
- - -
{children}
- - - -
- ); -} diff --git a/solution/site/src/app.test.tsx b/solution/site/src/app.test.tsx new file mode 100644 index 0000000..cc5611e --- /dev/null +++ b/solution/site/src/app.test.tsx @@ -0,0 +1,53 @@ +import {expect, it} from 'vitest'; +import {renderToStaticMarkup} from 'react-dom/server'; +import type {SitePayload} from '@o2o/shared'; +import {MOONLIGHT_STAY_PAYLOAD} from '@site/fixtures/moonlight-stay'; +import {App} from './App'; + +function withTemplate(templateId: string): SitePayload { + const payload = structuredClone(MOONLIGHT_STAY_PAYLOAD); + payload.theme.templateId = templateId; + return payload; +} + +it('템플릿이 가리키는 레이아웃의 Frame으로 그린다', () => { + const basic = renderToStaticMarkup(); + const paper = renderToStaticMarkup(); + expect(basic).toContain('shell flex h-16'); + expect(paper).toContain('class="w4p"'); + expect(basic).not.toContain('class="w4p"'); +}); + +it('여러 템플릿이 같은 레이아웃을 쓴다', () => { + const simple = renderToStaticMarkup(); + const retro = renderToStaticMarkup(); + expect(retro).toContain('shell flex h-16'); + expect(simple).toContain('shell flex h-16'); +}); + +it('등록되지 않은 templateId면 렌더를 멈춘다', () => { + expect(() => renderToStaticMarkup()).toThrow('등록되지 않은 템플릿'); + expect(() => renderToStaticMarkup()).toThrow('등록되지 않은 템플릿'); +}); + +it('첫 화면 문구가 따로 있어도 한 줄 요약이 화면에 나온다', () => { + for (const templateId of ['simple', 'paper']) { + const payload = withTemplate(templateId); + payload.narrative = {...payload.narrative, tagline: '첫 화면 문구', summary: '검색에 쓰는 한 줄 요약'}; + payload.facts = [...payload.facts, {...payload.facts[0], key: 'intro', label: '숙소 소개', value: '숙소 소개 원문입니다.', type: 'text'}]; + expect(renderToStaticMarkup()).toContain('검색에 쓰는 한 줄 요약'); + } +}); + +it('고택은 섹션 순서와 상관없이 날씨를 첫 화면 바로 아래에 둔다', () => { + const payload = withTemplate('paper'); + const weather = payload.theme.sections.find((section) => section.id === 'weather'); + if (weather) weather.enabled = true; + payload.theme.sections = [...payload.theme.sections.filter((s) => s.id !== 'weather'), ...(weather ? [weather] : [])]; + const html = renderToStaticMarkup(); + const hero = html.indexOf('class="hero"'); + const brief = html.indexOf('class="wbrief"'); + expect(hero).toBeGreaterThan(-1); + expect(brief).toBeGreaterThan(hero); + expect(html.slice(hero, brief)).not.toContain('class="sec'); +}); diff --git a/solution/site/src/entry-client.tsx b/solution/site/src/entry-client.tsx index d9634f0..89e1619 100644 --- a/solution/site/src/entry-client.tsx +++ b/solution/site/src/entry-client.tsx @@ -1,13 +1,12 @@ import {StrictMode, useEffect} from 'react'; import {createRoot, hydrateRoot} from 'react-dom/client'; -import type {SitePayload} from '@o2o/shared'; +import {templateOf, type SitePayload} from '@o2o/shared'; import {App} from './App'; import {fontHref, themeVars} from '@site/seo/head'; import './index.css'; declare global { interface Window { - /** 프리렌더가 HTML 안에 심어 둔 payload. 이게 있으면 하이드레이션, 없으면 개발 모드. */ __SITE_PAYLOAD__?: SitePayload; } } @@ -15,37 +14,12 @@ declare global { const container = document.getElementById('root')!; const injected = window.__SITE_PAYLOAD__; -/** - * 미리보기를 띄운 빌더에게 "이제 그림이 나왔다" 고 알린다. - * - * ★ 빌더는 iframe 의 `load` 만으로는 이 시점을 알 수 없다. `load` 는 **셸이 뜬** 순간이고, - * 그 뒤에 payload fetch + 웹폰트 대기(최대 2.5s) + `document.fonts.ready` 가 남아 있다. - * 그 사이 iframe 은 흰 화면인데, load 를 완료로 읽으면 로딩 표시가 바로 사라져 - * 사장님은 "다 됐다는데 아무것도 없는" 화면을 본다. - * ★ 두 번의 rAF 를 기다린다 — 첫 프레임은 React 가 DOM 을 붙인 직후라 아직 그려지기 전이다. - * ★ 실패해도 보낸다(ok=false). 안 보내면 빌더의 로딩 표시가 영영 안 걷힌다. - */ function signalPreviewPainted(ok: boolean) { if (window.parent === window) return; const post = () => window.parent.postMessage({type: 'o2o:preview-painted', ok}, window.location.origin); requestAnimationFrame(() => requestAnimationFrame(post)); } -/** - * 빌더 미리보기(iframe)가 여는 셸에서만 쓰는 경로 — `/preview?placeId=…`. - * - * ★ 왜 여기서 payload 를 가져오나 - * 미리보기는 **발행 전 최신 값**을 봐야 한다. 굽는 시점에는 어느 사업장을 미리 볼지 - * 모르므로 셸에 payload 를 심을 수 없다(prerender `writePreviewShell`). - * ★ 토큰은 부모(빌더)와 **같은 오리진의 localStorage** 에서 읽는다. 미리보기는 자기 서버가 - * 아니라 빌더가 쓰는 것과 같은 API 를 부르므로, 키도 그쪽과 같아야 한다 - * (`frontend/src/api/mutator/custom-fetch.ts`). - * ★ 색·서체 토큰과 **웹폰트 링크**를 발행본이 `` 에 굽는 것과 같은 함수로 만든다 - * (themeVars · fontHref). 셸은 어느 템플릿인지 모른 채 구워지므로 둘 다 여기서 얹는다. - * ★ 폰트를 빠뜨리면 조용히 틀린다 — 레이아웃·색은 그대로인데 글자만 기본 산세리프로 - * 떨어진다. 실측(2026-09-09): 간판체(Gugi)가 안 실려 픽셀 차이가 92% 였는데 - * 지오메트리(header·hero·h1 위치)는 발행본과 완전히 같았다. - */ function PreviewReady() { useEffect(() => signalPreviewPainted(true), []); return null; @@ -65,6 +39,7 @@ async function renderPreview(placeId: string) { }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const payload = (await res.json()) as SitePayload; + templateOf(payload.theme.templateId); for (const [name, value] of Object.entries(themeVars(payload))) { document.documentElement.style.setProperty(name, value); @@ -74,7 +49,6 @@ async function renderPreview(placeId: string) { font.rel = 'stylesheet'; font.href = fontHref(payload); document.head.appendChild(font); - // 글자가 기본 서체로 한 번 그려졌다 바뀌는 것을 줄인다. 못 받아도 렌더는 계속한다. await new Promise((resolve) => { font.addEventListener('load', () => resolve(), {once: true}); font.addEventListener('error', () => resolve(), {once: true}); @@ -98,8 +72,7 @@ async function renderPreview(placeId: string) { const previewPlaceId = new URLSearchParams(window.location.search).get('placeId'); if (injected) { - // 정적 HTML 위에 하이드레이션. 서버가 그린 마크업과 1:1 이어야 하므로 - // 여기서 payload 를 바꾸거나 fetch 를 걸지 않는다. + // 서버가 그린 마크업과 1:1이어야 하므로 payload를 바꾸지 않는다. hydrateRoot( container, @@ -108,12 +81,11 @@ if (injected) { ); } else if (previewPlaceId) { void renderPreview(previewPlaceId).catch((ex: unknown) => { - // 미리보기가 못 떠도 편집은 계속돼야 한다. 부모 창이 읽을 수 있게 이유를 남긴다. container.textContent = `미리보기를 불러오지 못했습니다 — ${ex instanceof Error ? ex.message : String(ex)}`; signalPreviewPainted(false); }); } else { - // 개발 서버(vite dev) — 데모 payload 로 CSR 렌더. 프로덕션 경로가 아니다. + // 개발 서버 전용 데모 payload. void import('./fixtures/moonlight-stay').then(({MOONLIGHT_STAY_PAYLOAD}) => { createRoot(container).render( diff --git a/solution/site/src/entry-server.tsx b/solution/site/src/entry-server.tsx index 7c565f8..711c3f6 100644 --- a/solution/site/src/entry-server.tsx +++ b/solution/site/src/entry-server.tsx @@ -3,14 +3,7 @@ import {renderToString} from 'react-dom/server'; import type {SitePayload} from '@o2o/shared'; import {App} from './App'; -/** - * SSR 엔트리. prerender 스크립트가 이 함수를 불러 HTML 을 얻는다. - * - * ★ renderToString 을 쓴다(스트리밍이 아니라). 파일로 굽는 게 목적이라 - * 전체 문자열이 한 번에 필요하고, 스트리밍의 이점이 없다. - * - * ★ 사이트가 한 장이라 라우터도 url 인자도 없다. 섹션 이동은 앵커다. - */ +/** SSR 엔트리. */ export function render(payload: SitePayload): string { return renderToString( diff --git a/solution/site/src/fixtures/moonlight-stay.ts b/solution/site/src/fixtures/moonlight-stay.ts index 38fb5f9..fec082a 100644 --- a/solution/site/src/fixtures/moonlight-stay.ts +++ b/solution/site/src/fixtures/moonlight-stay.ts @@ -4,22 +4,16 @@ import { PlaceCategory, SiteStatus, SourceType, + TEMPLATES, type FactEntry, type SitePayload, } from '@o2o/shared'; -/** - * 데모 payload — "달빛스테이 제주"(숙박). - * - * 백엔드 sites API 가 붙기 전까지 프리렌더와 개발 서버가 먹는 입력이다. - * ★ 일부러 `pickup_service` 한 건을 UNVERIFIED 로 남겨 두었다. - * 확인 안 된 값이 화면·JSON-LD·llms.txt 어디에도 안 나가는지 눈으로 확인하는 용도다. - * 전부 VERIFIED 인 fixture 로는 그 규칙이 지켜지는지 알 수 없다. - */ +/** 데모 payload — "달빛스테이 제주"(숙박). */ const UPDATED_AT = '2026-08-27T02:00:00+09:00'; -/** fact 한 건을 짧게 만드는 헬퍼. 기본값은 "확인됨". */ +/** fact 한 건을 짧게 만드는 헬퍼. */ function fact( key: string, label: string, @@ -66,7 +60,7 @@ const PLACE_FACTS: FactEntry[] = [ '30000', {type: 'number', unit: '원', critical: true}, ), - // ★ 확인 전 — 화면에도, JSON-LD 에도, llms.txt 에도 나오면 안 된다. + // 확인 전 — 화면에도, JSON-LD 에도, llms.txt 에도 나오면 안 된다. fact('pickup_service', '픽업 서비스', 'true', { type: 'bool', status: FactStatus.UNVERIFIED, @@ -389,7 +383,7 @@ export const MOONLIGHT_STAY_PAYLOAD: SitePayload = { confirmed: true, }, { - // ★ 확정 전 — sameAs 와 푸터 어디에도 나오면 안 된다. + // 확정 전 — sameAs 와 푸터 어디에도 나오면 안 된다. channel: LinkChannel.YANOLJA, url: 'https://www.yanolja.com/pension/0000000', title: '야놀자', @@ -506,14 +500,7 @@ export const MOONLIGHT_STAY_PAYLOAD: SitePayload = { }, ], - /** - * 노래는 비워 둔다. - * - * ★ 이 픽스처의 목적은 "payload 가 이러이러할 때 화면이 이렇게 나온다" 를 눈으로 보는 것이다. - * 노래는 발행 때 Suno 가 만들어 넣는 실제 파일을 가리키므로, 여기에 가짜 주소를 적으면 - * 개발 서버에서 **재생만 안 되는 버튼**이 생긴다. 비어 있을 때 플레이어가 아예 안 그려지는지 - * 확인하는 쪽이 이 픽스처가 할 일에 맞다. - */ + /** 노래는 비워 둔다. */ songs: [], narrative: { @@ -530,7 +517,7 @@ export const MOONLIGHT_STAY_PAYLOAD: SitePayload = { }, theme: { - templateId: 'stay-warm-wood', + templateId: 'simple', colors: { primary: '#43302b', secondary: '#786055', @@ -539,15 +526,12 @@ export const MOONLIGHT_STAY_PAYLOAD: SitePayload = { text: '#29201d', accent: '#c27847', }, - fontStyle: 'Warm Natural', + look: TEMPLATES.simple.look, sections: [ {id: 'hero', name: '히어로', enabled: true, locked: true}, {id: 'intro', name: '소개', enabled: true, locked: false}, {id: 'rooms', name: '객실 안내', enabled: true, locked: false}, {id: 'info', name: '기본 정보', enabled: true, locked: true}, - // ★ 서버 기본표(`site_payload._DEFAULT_THEME`)의 숙박 목록에 있는 두 섹션이 - // fixture 에는 빠져 있었다. 그래서 개발 서버로는 이용 규정·예약 안내가 보이지 않아 - // "발행하면 나오는데 여기서는 안 나온다" 를 확인할 수 없었다. {id: 'rules', name: '이용 규정', enabled: true, locked: false}, {id: 'booking', name: '예약 안내', enabled: true, locked: false}, {id: 'photos', name: '사진 갤러리', enabled: true, locked: false}, diff --git a/solution/site/src/index.css b/solution/site/src/index.css index b2810f9..be03d64 100644 --- a/solution/site/src/index.css +++ b/solution/site/src/index.css @@ -1,19 +1,9 @@ @import "tailwindcss"; @import "@o2o/shared/styles/base.css"; -/* 시안 토큰·유틸(타이포·.shell·.h2·.panel·슬라이더)은 캔버스와 한 파일을 쓴다. - ★ 값을 두 곳에 적으면 한쪽만 고쳐지고 에디터와 발행본이 다시 갈라진다. */ +/* 시안 토큰·유틸(타이포·.shell·.h2·.panel·슬라이더)은 캔버스와 한 파일을 쓴다. */ @import "@o2o/shared/styles/site.css"; -/* - * 발행 사이트의 디자인 토큰. - * - * 색·서체·모서리·테두리·여백은 전부 사장님이 고른 템플릿에서 온다. 프리렌더가 에 - * `--tpl-*` 를 심고(`seo/head.ts` themeStyle) 여기서 Tailwind 토큰으로 연결한다. - * 관리자 토큰(tokens.css)은 이 앱에 들어오지 않는다 — 두 색 체계를 섞지 않는다. - * - * ★ 이 파일에 하드코딩된 색을 두지 않는다. 아래 :root 값은 주입이 없는 - * 개발 서버(`npm run dev:site`)용 폴백이다. - */ +/* 발행 사이트의 디자인 토큰. */ :root { --tpl-primary: #18181b; --tpl-secondary: #52525b; @@ -26,12 +16,7 @@ --tpl-inverse: #1c1917; --tpl-border: #e7e5e4; - /* - * 생김새 폴백 = '심플' 템플릿(admin `industryData.ts` 의 LOOK.simple). - * ★ 예전엔 서체 폴백이 명조였다. 발행 잡은 이제 항상 look 을 실어 보내지만 - * (backend `_DEFAULT_LOOK`), 개발 서버에는 주입이 없어 이 값이 곧 화면이 된다 — - * 폴백이 제품 기본값과 다르면 개발자가 보는 것과 손님이 보는 것이 갈린다. - */ + /* 생김새 폴백 = '심플' 템플릿(admin `industryData.ts` 의 LOOK.simple). */ --tpl-font-heading: 'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif; --tpl-font-body: 'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif; --tpl-radius: 0.75rem; @@ -53,28 +38,12 @@ --font-sans: 'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif; --font-serif: 'Noto Serif KR', 'Batang', 'Times New Roman', serif; - /* - * 선 색은 **둘레 글자색에서 뽑는다.** - * 예전에는 전 섹션이 `border-black/8` 이었다 — 어두운 바탕(푸터·필름·플립보드)에서 - * 검은 선은 그냥 안 보인다. currentColor 를 섞으면 밝은 면에서는 진해지고 - * 어두운 면에서는 밝아져, 어떤 팔레트에서도 선이 남는다. - * - * ★ 여기(@theme inline)에 있어야 하는 이유 — Tailwind 가 이 이름으로 - * `text-muted` · `border-line` · `divide-line` 유틸을 **만들어 준다.** - * 공유 파일(shared/styles/site.css)로 옮겼더니 유틸 생성이 끊겨, 마크업이 188곳에서 - * 쓰는 `text-muted` 와 101곳의 `border-line` 이 통째로 죽었다(실측 2026-09-09). - * 공유 파일에는 캔버스용 같은 값의 명시 정의가 따로 있다 — 값이 같으니 어느 쪽이 이겨도 된다. - */ --color-line: color-mix(in oklab, currentColor 14%, transparent); --color-line-soft: color-mix(in oklab, currentColor 8%, transparent); - /* 본문 보조 글자. opacity-60 을 반복해 쓰던 자리를 색 하나로 모은다. */ + /* 본문 보조 글자. */ --color-muted: color-mix(in oklab, currentColor 96%, transparent); - /* - * 모서리는 템플릿이 정한 값 하나에서 **비율로** 펼친다. - * 하나로 통일하면 칩과 카드가 같은 곡률이라 층이 안 보이고, - * 따로 박으면 레트로(0px)를 골라도 카드만 둥글게 남는다. - */ + /* 모서리는 템플릿이 정한 값 하나에서 **비율로** 펼친다. */ --radius-sm: calc(var(--tpl-radius, 0.75rem) * 0.4); --radius-md: calc(var(--tpl-radius, 0.75rem) * 0.7); --radius-lg: var(--tpl-radius, 0.75rem); @@ -85,7 +54,7 @@ html { scroll-behavior: smooth; - /* 고정 헤더에 앵커가 가리지 않게. 헤더 높이(4rem)+여유. */ + /* 고정 헤더에 앵커가 가리지 않게. */ scroll-padding-top: 5.5rem; } @@ -93,8 +62,7 @@ body { background-color: var(--color-surface); background-image: var(--tpl-texture, none); color: var(--color-ink); - /* ★ 본문 서체도 템플릿이 정한다(theme.look → seo/head.ts). 토큰이 없는 옛 payload 에서는 - 지금까지와 똑같이 Pretendard/Noto Sans KR 로 떨어진다. */ + /* 본문 서체도 템플릿이 정한다(theme.look → seo/head.ts). */ font-family: var(--tpl-font-body, var(--font-sans)); font-size: var(--fs-body); font-weight: 450; @@ -104,13 +72,12 @@ body { padding-bottom: env(safe-area-inset-bottom); } -/* 키보드 사용자에게만 보이는 초점 테두리. 마우스 클릭에는 안 뜬다. */ +/* 키보드 사용자에게만 보이는 초점 테두리. */ :focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; } -/* 날씨 문구가 갈릴 때 접었다 편다(WeatherSection.tsx — key 로 다시 태워 재생시킨다). */ @keyframes w4-note-fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } diff --git a/solution/site/src/layouts/basic/Frame.tsx b/solution/site/src/layouts/basic/Frame.tsx new file mode 100644 index 0000000..062ce24 --- /dev/null +++ b/solution/site/src/layouts/basic/Frame.tsx @@ -0,0 +1,13 @@ +import type {ReactNode} from 'react'; +import {MobileTabBar, SiteFooter, SiteHeader} from '@site/sections'; + +export function Frame({children}: {children: ReactNode}) { + return ( +
+ +
{children}
+ + +
+ ); +} diff --git a/solution/site/src/layouts/bigtype/Around.tsx b/solution/site/src/layouts/bigtype/Around.tsx new file mode 100644 index 0000000..7d80daf --- /dev/null +++ b/solution/site/src/layouts/bigtype/Around.tsx @@ -0,0 +1,144 @@ +import {useState} from 'react'; +import type {LocalPlace} from '@o2o/shared'; +import {useSite} from '@site/lib/site-context'; +import {naverMapSearchUrl} from '@site/lib/format'; +import {Sheet} from '../paper/Sheet'; +import {Idx} from './SectionHead'; +import {Tabs} from './Tabs'; +import {useWide} from './useWide'; + +const WALK = 80; +const RANGES = [ + {id: 'all', label: '전체', test: () => true}, + {id: 'w5', label: '걸어서 5분 이내', test: (m: number) => m <= 5 * WALK}, + {id: 'w10', label: '걸어서 10분 이내', test: (m: number) => m <= 10 * WALK}, + {id: 'far', label: '걸어서 10분 이상', test: (m: number) => m > 10 * WALK}, +] as const; +const HOME_ROWS = 3; +const FIRST_ROWS = 5; + +function meters(place: LocalPlace): number { + if (place.distanceMeters != null) return place.distanceMeters; + const m = /^([\d.]+)\s*(km|m)$/i.exec((place.distanceText ?? '').trim()); + return m ? Number(m[1]) * (m[2].toLowerCase() === 'km' ? 1000 : 1) : Infinity; +} + +const byDistance = (a: LocalPlace, b: LocalPlace) => meters(a) - meters(b); +const inRange = (id: string) => RANGES.find((r) => r.id === id)?.test ?? (() => true); + +function rangeTabs(places: LocalPlace[]) { + return RANGES.filter((r, i) => { + const n = places.filter((p) => r.test(meters(p))).length; + return i === 0 || (n > 0 && n < places.length); + }).map((r) => ({id: r.id as string, label: `${r.label} ${places.filter((p) => r.test(meters(p))).length}`})); +} + +export function Around() { + const payload = useSite(); + const {local} = payload; + const spots = [...local.attractions].sort(byDistance); + const eats = [...local.restaurants].sort(byDistance); + const rail = [...spots, ...eats].filter((place) => place.imageUrl).sort(byDistance); + const [open, setOpen] = useState(null); + const [spotRange, setSpotRange] = useState('all'); + const [eatRange, setEatRange] = useState('all'); + const firstSpots = useWide() ? 8 : 6; + if (rail.length === 0 && eats.length === 0 && spots.length === 0) return null; + + const shownSpots = spots.filter((p) => inRange(spotRange)(meters(p))); + const shownEats = eats.filter((p) => inRange(eatRange)(meters(p))); + const item = (place: LocalPlace, i: number, thumb = true) => ( +
  • + +
  • + ); + + return ( + <> + {rail.length > 0 && ( +
    +
    + +
      {rail.slice(0, HOME_ROWS).map((place, i) => item(place, i))}
    + + 지역 소개에서 모두 보기 + → + +
    +
    + )} + +
    + {spots.length > 0 && ( +
    + + +
      {shownSpots.slice(0, firstSpots).map((place, i) => item(place, i))}
    + {shownSpots.length > firstSpots && ( +
    + + {shownSpots.length - firstSpots}곳 더 보기 + + + +
      + {shownSpots.slice(firstSpots).map((place, i) => item(place, i + firstSpots))} +
    +
    + )} +
    + )} + {eats.length > 0 && ( +
    + + +
      {shownEats.slice(0, FIRST_ROWS).map((place, i) => item(place, i, false))}
    + {shownEats.length > FIRST_ROWS && ( +
    + + {shownEats.length - FIRST_ROWS}곳 더 보기 + + + +
      + {shownEats.slice(FIRST_ROWS).map((place, i) => item(place, i + FIRST_ROWS, false))} +
    +
    + )} +
    + )} +
    + + setOpen(null)} label={open?.name ?? ''}> + {open && ( + <> + {open.imageUrl && {open.name}} +

    {open.name}

    +

    {[open.distanceText, open.category].filter(Boolean).join(' · ')}

    + {open.description &&

    {open.description}

    } + + 네이버에서 보기 + ↗ + + + )} +
    + + ); +} diff --git a/solution/site/src/layouts/bigtype/Booking.tsx b/solution/site/src/layouts/bigtype/Booking.tsx new file mode 100644 index 0000000..28681c1 --- /dev/null +++ b/solution/site/src/layouts/bigtype/Booking.tsx @@ -0,0 +1,63 @@ +import {PlaceCategory} from '@o2o/shared'; +import {useSite} from '@site/lib/site-context'; +import {bookingActionLabel, bookingLinks, stayBookingView} from '@site/lib/derive'; +import {BOOK_EVENT} from '../paper/Rooms'; + +function Block({id, full}: {id: string; full?: boolean}) { + const payload = useSite(); + const links = bookingLinks(payload); + const stay = payload.place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null; + const phone = payload.place.phone; + + return ( +
    +

    + Reservation +

    +

    + 이용안내 +
    및 예약 +

    +

    요금과 예약 방법을 안내합니다.

    +
    + {(full ? links : links.slice(0, 1)).map((link) => ( + + {bookingActionLabel(link)} + ↗ + + ))} + {full && phone && ( + + {phone} + → + + )} + {full && stay && ( + + )} + {!full && ( + + 이용안내 보기 + → + + )} +
    +
    + ); +} + +export function Booking() { + return ( + <> +
    + +
    +
    + +
    + + ); +} diff --git a/solution/site/src/layouts/bigtype/Faq.tsx b/solution/site/src/layouts/bigtype/Faq.tsx new file mode 100644 index 0000000..a9347bc --- /dev/null +++ b/solution/site/src/layouts/bigtype/Faq.tsx @@ -0,0 +1,41 @@ +import {SourceType} from '@o2o/shared'; +import {useSite} from '@site/lib/site-context'; +import {faqList} from '@site/lib/derive'; +import {Idx} from './SectionHead'; + +const FIRST = 6; + +export function Faq() { + const payload = useSite(); + const faqs = faqList(payload); + if (faqs.length === 0) return null; + const verified = !faqs.some((faq) => faq.sourceType === SourceType.TEMPLATE); + const item = (faq: (typeof faqs)[number], i: number) => ( +
  • +
    + + Q{i + 1} + {faq.question} + +

    {faq.answer}

    +
    +
  • + ); + + return ( +
    + + {verified &&

    아래 답변은 모두 사업자가 확인한 내용입니다.

    } +
      {faqs.slice(0, FIRST).map(item)}
    + {faqs.length > FIRST && ( +
    + + {faqs.length - FIRST}개 더 보기 + + + +
      {faqs.slice(FIRST).map((faq, i) => item(faq, i + FIRST))}
    +
    + )} +
    + ); +} diff --git a/solution/site/src/layouts/bigtype/Festival.tsx b/solution/site/src/layouts/bigtype/Festival.tsx new file mode 100644 index 0000000..30c33ef --- /dev/null +++ b/solution/site/src/layouts/bigtype/Festival.tsx @@ -0,0 +1,72 @@ +import {useState} from 'react'; +import type {FestivalEntry} from '@o2o/shared'; +import {useSite} from '@site/lib/site-context'; +import {naverSearchUrl} from '@site/lib/format'; +import {Car} from '../paper/Car'; +import {Sheet} from '../paper/Sheet'; +import {Idx} from './SectionHead'; +import {Tabs} from './Tabs'; + +const SEASONS = ['봄', '여름', '가을', '겨울']; + +export function Festival() { + const payload = useSite(); + const festivals = payload.local.festivals; + const [season, setSeason] = useState(''); + const [open, setOpen] = useState(null); + if (festivals.length === 0) return null; + const tabs = ['', ...SEASONS.filter((s) => festivals.some((f) => f.season === s))].map((s) => ({ + id: s, + label: `${s || '전체'} ${festivals.filter((f) => !s || f.season === s).length}`, + })); + const shown = festivals.filter((f) => !season || f.season === season); + + return ( +
    + +

    {payload.place.addressLocality ?? '이 지역'}의 축제와 행사를 계절로 묶었습니다.

    + {tabs.length > 2 && } +
    + ( + + ))} + /> +
    + setOpen(null)} label={open?.name ?? ''}> + {open && ( + <> + {open.imageUrl && {open.name}} +

    {open.name}

    +

    {[open.period ?? open.month, open.location].filter(Boolean).join(' · ')}

    + {open.description &&

    {open.description}

    } + + 자세히 보기 + ↗ + + + )} +
    +
    + ); +} diff --git a/solution/site/src/layouts/bigtype/Frame.tsx b/solution/site/src/layouts/bigtype/Frame.tsx new file mode 100644 index 0000000..785c14f --- /dev/null +++ b/solution/site/src/layouts/bigtype/Frame.tsx @@ -0,0 +1,276 @@ +import {useEffect, useState, type CSSProperties, type ReactNode} from 'react'; +import {PlaceCategory, selectPublishable} from '@o2o/shared'; +import {useSite} from '@site/lib/site-context'; +import { + bookingActionLabel, + bookingLinks, + channelLabel, + isSectionEnabled, + publicLinks, + stayBookingView, +} from '@site/lib/derive'; +import {isoDate, naverDirectionsUrl} from '@site/lib/format'; +import {SongPlayer} from '@site/sections/SongPlayer'; +import {BookingForm} from '../paper/BookingForm'; +import {BOOK_EVENT} from '../paper/Rooms'; +import {Sheet} from '../paper/Sheet'; +import {Idx} from './SectionHead'; +import {wordmark} from './wordmark'; +import '../kit/kit.css'; +import './bigtype.css'; + +const HOUSE_WORD: Record = { + [PlaceCategory.LODGING]: '펜션', + [PlaceCategory.CAFE]: '카페', + [PlaceCategory.RESTAURANT]: '가게', + [PlaceCategory.CLINIC]: '병원', +}; + +type PageId = 'home' | 'area' | 'stay' | 'story'; + +const PAGE_HEAD: Record, {title: string; note: string}> = { + area: {title: '지역', note: '대문에서 걸어 닿는 자리와, 이 도시가 남긴 이야기입니다.'}, + stay: {title: '이용안내', note: '객실 상세와 요금, 예약하는 방법입니다.'}, + story: {title: '이야기', note: '사장님이 남기는 글과 다녀가신 분들의 후기, 그리고 엽서 한 장입니다.'}, +}; + +function pageOf(hash: string): PageId { + const id = hash.replace(/^#\/?/, ''); + return id === 'area' || id === 'stay' || id === 'story' ? id : 'home'; +} + +function usePage(): PageId { + const [page, setPage] = useState('home'); + useEffect(() => { + const sync = () => { + const {hash} = window.location; + if (hash && !hash.startsWith('#/')) return; + setPage(pageOf(hash)); + window.scrollTo({top: 0, behavior: 'instant'}); + }; + sync(); + window.addEventListener('hashchange', sync); + const again = (event: MouseEvent) => { + const link = event.target instanceof Element ? event.target.closest('a[href^="#/"]') : null; + const href = link?.getAttribute('href'); + if (href && pageOf(href) === pageOf(window.location.hash)) window.scrollTo({top: 0, behavior: 'instant'}); + }; + document.addEventListener('click', again); + return () => { + window.removeEventListener('hashchange', sync); + document.removeEventListener('click', again); + }; + }, []); + return page; +} + +export function Frame({children}: {children: ReactNode}) { + const payload = useSite(); + const {place, site} = payload; + const page = usePage(); + const [bookingOpen, setBookingOpen] = useState(false); + useEffect(() => { + const open = () => setBookingOpen(true); + window.addEventListener(BOOK_EVENT, open); + return () => window.removeEventListener(BOOK_EVENT, open); + }, []); + + const tabs: {label: string; page: PageId}[] = [ + {label: `${HOUSE_WORD[place.category] ?? '가게'} 소개`, page: 'home'}, + ]; + if (isSectionEnabled(payload, 'local')) tabs.push({label: '지역', page: 'area'}); + tabs.push({label: '이용안내', page: 'stay'}); + tabs.push({label: '이야기', page: 'story'}); + + const mark = wordmark(place.name); + const markStyle = {'--wm': `calc(${Math.min(41, 86 / mark.units)} * var(--u))`} as CSSProperties; + const booking = bookingLinks(payload)[0]; + const stayBooking = place.category === PlaceCategory.LODGING ? stayBookingView(payload) : null; + const kakao = payload.links.find((link) => link.confirmed && /kakao/i.test(link.url)); + const address = place.roadAddress ?? place.address; + const hasSongs = (payload.songs ?? []).some((song) => song.audioUrl); + const facts = selectPublishable(payload.facts); + const fact = (key: string) => facts.find((f) => f.key === key)?.value ?? undefined; + const hours = [ + fact('check_in_time') && `체크인 ${fact('check_in_time')}`, + fact('check_out_time') && `체크아웃 ${fact('check_out_time')}`, + fact('smoking') === 'false' && '전 구역 금연', + ].filter(Boolean); + const channels = publicLinks(payload); + const instagram = channels.find((link) => /instagram\.com/i.test(link.url)); + const locality = place.addressLocality; + const areaPills = ( + [ + ['주변 명소', '#walk', payload.local.attractions.length > 0], + ['주변 맛집', '#eat', payload.local.restaurants.length > 0], + ['엽서', '#/story', true], + ['축제', '#festival', isSectionEnabled(payload, 'festival') && payload.local.festivals.length > 0], + ['일정', '#itinerary', isSectionEnabled(payload, 'itinerary')], + ['이야기', '#story', isSectionEnabled(payload, 'story')], + ] as [string, string, boolean][] + ).filter(([, , on]) => on); + + const barItems = [address ? 'map' : null, kakao ? 'talk' : null, place.phone ? 'tel' : null].filter(Boolean); + const hasCta = Boolean(stayBooking || booking); + const showBar = barItems.length > 0 || hasCta; + const columns = [...barItems.map(() => '1fr'), ...(hasCta ? ['1.4fr'] : [])].join(' '); + const cta = stayBooking ? ( + + ) : ( + booking && ( + + {bookingActionLabel(booking)} + + ) + ); + + return ( +
    +
    + {place.name} + {place.phone ? Call (+) : Menu (+)} +
    + +
    +

    + {mark.lines.map((line) => ( + {line} + ))} +

    +
    + {locality && {locality}} + {place.latitude != null && place.longitude != null && ( + + {place.latitude.toFixed(1)}°N {place.longitude.toFixed(1)}°E + + )} + {HOUSE_WORD[place.category] ?? '가게'} +
    +
    + + + + {(Object.keys(PAGE_HEAD) as Exclude[]).map((id) => ( +
    +

    + {id === 'area' ? (place.addressLocality?.replace(/(시|군)$/, '') ?? PAGE_HEAD.area.title) : PAGE_HEAD[id].title} +

    +

    {PAGE_HEAD[id].note}

    + {id === 'area' && areaPills.length > 0 && ( +
    + {areaPills.map(([label, href]) => ( + + {label} + + ))} +
    + )} +
    + ))} + + {children} + + {channels.length > 0 && ( +
    +
    + + {channels.map((link) => ( + + {channelLabel(link)} + Open ↗ + + ))} +
    +
    + )} + +
    +

    + {place.name} + {address && ` — ${address}`} +

    + {hours.length > 0 &&

    {hours.join(' · ')}

    } + {(place.phone || instagram) && ( +

    + {place.phone && {place.phone}} + {place.phone && instagram && ' · '} + {instagram && ( + + instagram + + )} +

    + )} + {place.legal?.businessRegistrationNumber && ( +

    + {place.legal.representative && `대표 ${place.legal.representative} · `} + 사업자등록번호 {place.legal.businessRegistrationNumber} + {place.legal.mailOrderNumber && ` · 통신판매업신고 ${place.legal.mailOrderNumber}`} +

    + )} +

    + 작성·운영 {place.name} · 최종 업데이트{' '} + ·{' '} + + AI O2O + + 의 Web4Ai로 만든 사이트 +

    +

    + {mark.lines[0]}. +

    +
    + + {hasSongs && ( +
    + +
    + )} + + {showBar && ( + + )} + + {stayBooking && ( + setBookingOpen(false)} label="예약 요청" plain> +
    +

    예약 요청

    + +
    +
    + )} +
    + ); +} diff --git a/solution/site/src/layouts/bigtype/Gallery.tsx b/solution/site/src/layouts/bigtype/Gallery.tsx new file mode 100644 index 0000000..05de778 --- /dev/null +++ b/solution/site/src/layouts/bigtype/Gallery.tsx @@ -0,0 +1,53 @@ +import {useState} from 'react'; +import type {MediaItem} from '@o2o/shared'; +import {useSite} from '@site/lib/site-context'; +import {galleryImages, sectionName} from '@site/lib/derive'; +import {Sheet} from '../paper/Sheet'; +import {Idx} from './SectionHead'; + +const FIRST = 5; + +export function Gallery() { + const payload = useSite(); + const images = galleryImages(payload); + const [open, setOpen] = useState(null); + if (images.length === 0) return null; + + const cell = (image: MediaItem) => ( + + ); + + return ( +
    + + + setOpen(null)} label={open?.alt ?? ''}> + {open && ( + <> + {open.alt} +

    {open.alt}

    + {open.category &&

    {open.category}

    } + + )} +
    +
    + ); +} diff --git a/solution/site/src/layouts/bigtype/Hero.tsx b/solution/site/src/layouts/bigtype/Hero.tsx new file mode 100644 index 0000000..fda639b --- /dev/null +++ b/solution/site/src/layouts/bigtype/Hero.tsx @@ -0,0 +1,72 @@ +import {PlaceCategory} from '@o2o/shared'; +import {HeroCatchphrase} from '@site/sections/HeroCatchphrase'; +import {useSite} from '@site/lib/site-context'; +import {bookingActionLabel, bookingLinks, isSectionEnabled, stayBookingView} from '@site/lib/derive'; +import {BOOK_EVENT} from '../paper/Rooms'; +import {Weather} from './Weather'; + +export function Hero() { + const payload = useSite(); + const {place, narrative} = payload; + const [first, second] = [...payload.media] + .filter((image) => image.alt?.trim()) + .sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary)); + const fixed = narrative.tagline ?? narrative.heroSubline; + const lodging = place.category === PlaceCategory.LODGING; + const stay = lodging ? stayBookingView(payload) : null; + const link = bookingLinks(payload)[0]; + + return ( + <> +
    + {first && ( +
    + {first.alt} +
    + )} +
    +

    + 오늘의 문장 +

    + {(lodging || fixed) &&

    {lodging ? : fixed}

    } + {lodging && fixed &&

    {fixed}

    } +
    + {stay ? ( + + ) : ( + link && ( + + {bookingActionLabel(link)} + ↗ + + ) + )} + {place.phone && ( + + {place.phone} + Call + + )} + + 이용안내 + → + +
    +
    + {second && ( +
    + {second.alt} +
    + )} +
    + {isSectionEnabled(payload, 'weather') && ( +
    + +
    + )} + + ); +} diff --git a/solution/site/src/layouts/bigtype/Info.tsx b/solution/site/src/layouts/bigtype/Info.tsx new file mode 100644 index 0000000..a22a436 --- /dev/null +++ b/solution/site/src/layouts/bigtype/Info.tsx @@ -0,0 +1,96 @@ +import {useSite} from '@site/lib/site-context'; +import type {InfoRow} from '@site/lib/derive'; +import {infoView, ON_VALUES} from '@site/sections/EssentialInfoSection'; +import {Idx} from './SectionHead'; + +function Row({row}: {row: InfoRow}) { + const long = Boolean(row.note) || row.value.length > 24; + return ( +
    + {row.label} + + {row.value} + {row.note && ` — ${row.note}`} + +
    + ); +} + +function Mark({on}: {on: boolean}) { + return ( + + {on ? : } + + ); +} + +export function Info() { + const payload = useSite(); + const view = infoView(payload); + if (!view) return null; + const {ruleRows, detailRows, amenities, notices} = view; + const short = ruleRows + .filter((row) => !row.note && row.value.length <= 24) + .sort((a, b) => Number(b.key === 'check_in_time') - Number(a.key === 'check_in_time')); + const long = ruleRows.filter((row) => row.note || row.value.length > 24); + const sorted = [...amenities].sort((a, b) => Number(ON_VALUES.has(b.value)) - Number(ON_VALUES.has(a.value))); + + return ( +
    + +

    방문 전 확인이 필요한 운영 규정과 시설 안내입니다.

    + {ruleRows.length > 0 && ( + <> +

    예약 전 확인

    +
    + {[...short, ...long].map((row) => ( + + ))} +
    + + )} + {detailRows.length + sorted.length > 0 && ( + <> +

    시설 · 편의

    + {detailRows.length > 0 && ( +
    + {detailRows.map((row) => ( + + ))} +
    + )} + {sorted.length > 0 && ( +
      + {sorted.map((row) => { + const on = ON_VALUES.has(row.value); + return ( +
    • + + + {row.label} {row.value} + +
    • + ); + })} +
    + )} + + )} + {notices.length > 0 && ( +
      + {notices.map((text) => ( +
    • +
      + + ! + 예약 공지 + +

      {text}

      +
      +
    • + ))} +
    + )} +
    + ); +} diff --git a/solution/site/src/layouts/bigtype/Intro.tsx b/solution/site/src/layouts/bigtype/Intro.tsx new file mode 100644 index 0000000..1effad4 --- /dev/null +++ b/solution/site/src/layouts/bigtype/Intro.tsx @@ -0,0 +1,118 @@ +import {useSite} from '@site/lib/site-context'; +import {Idx} from './SectionHead'; +import {essentialRows, galleryImages, sectionBody} from '@site/lib/derive'; + +const SPEC_KEYS = ['check_in_time', 'check_out_time', 'standard_capacity', 'max_capacity', 'parking']; + +function dotDate(iso: string): string { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso); + return m ? `${m[1]}. ${m[2]}. ${m[3]}` : iso; +} + +function Post({date, body}: {date: string; body: string}) { + return ( +
    +

    + +

    +

    {body}

    +
    + ); +} + +const HOME_MAX = 3; +const FULL_FIRST = 5; + +export function Blog({full}: {full?: boolean}) { + const payload = useSite(); + const posts = [...(payload.posts ?? [])].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)); + if (posts.length === 0) return null; + const shown = posts.slice(0, full ? FULL_FIRST : HOME_MAX); + const older = full ? posts.slice(FULL_FIRST) : []; + const more = !full && posts.length > HOME_MAX; + + return ( +
    + +
    + {shown.map((item) => ( + + ))} +
    + {more && ( + + 이야기에서 지난 소식 더 보기 + → + + )} + {older.length > 0 && ( +
    + + 지난 소식 {older.length}개 + + + + {older.map((item) => ( + + ))} +
    + )} +
    + ); +} + +export function Intro() { + const payload = useSite(); + const {narrative, place} = payload; + const own = sectionBody(payload, 'intro'); + const about = own.length > 0 ? own : narrative.about; + const image = galleryImages(payload).find((m) => !m.isPrimary); + const spec = essentialRows(payload) + .filter((row) => row.key && SPEC_KEYS.includes(row.key)) + .sort((a, b) => SPEC_KEYS.indexOf(a.key!) - SPEC_KEYS.indexOf(b.key!)); + + return ( + <> +
    + +
    +
    + + {about.length > 0 && ( +
    + +
    + {narrative.summary &&

    {narrative.summary}

    } +

    {about[0]}

    + {about.length > 1 && ( +
    + + 더 읽기 + + + + {about.slice(1).map((paragraph, i) => ( +

    {paragraph}

    + ))} +
    + )} + {spec.length > 0 && ( +
    + {spec.map((row) => ( +
    + {row.label} + {row.value} +
    + ))} +
    + )} +
    +
    + )} + {image && ( +
    + {image.alt} +
    + )} +
    + + ); +} diff --git a/solution/site/src/layouts/bigtype/Itinerary.tsx b/solution/site/src/layouts/bigtype/Itinerary.tsx new file mode 100644 index 0000000..1b7bbd0 --- /dev/null +++ b/solution/site/src/layouts/bigtype/Itinerary.tsx @@ -0,0 +1,102 @@ +import {useState} from 'react'; +import type {ItineraryItem, PlannerStop} from '@o2o/shared'; +import {useSite} from '@site/lib/site-context'; +import {sectionItems, sectionName} from '@site/lib/derive'; +import {Sheet} from '../paper/Sheet'; +import {Idx} from './SectionHead'; +import {Tabs} from './Tabs'; + +const FIRST = 4; + +function stopsOf(item: ItineraryItem): PlannerStop[] { + return item.days?.[0]?.stops ?? item.stops ?? []; +} + +export function Itinerary() { + const payload = useSite(); + const parsed = sectionItems(payload, 'itinerary'); + const [tag, setTag] = useState(''); + const [open, setOpen] = useState(null); + const items = parsed.items; + if (items.length === 0) return null; + const tags = [...new Set(items.map((item) => item.duration).filter((d): d is string => Boolean(d)))]; + const shown = items.filter((item) => !tag || item.duration === tag); + + const card = (item: ItineraryItem, i: number) => ( +
  • + +
  • + ); + + return ( +
    + +

    {parsed.subtitle || '하루를 어떻게 쓸지 미리 짜 두었습니다.'}

    + {tags.length > 1 && ( + ({ + id: t, + label: `${t || '전체'} ${items.filter((item) => !t || item.duration === t).length}`, + }))} + value={tag} + onChange={setTag} + /> + )} +
      {shown.slice(0, FIRST).map(card)}
    + {shown.length > FIRST && ( +
    + + {shown.length - FIRST}개 더 보기 + + + +
      {shown.slice(FIRST).map((item, i) => card(item, i + FIRST))}
    +
    + )} + setOpen(null)} label={open?.name ?? ''}> + {open && ( + <> +

    {open.name}

    +

    {[open.duration, open.audience].filter(Boolean).join(' · ')}

    + {open.why &&

    {open.why}

    } + {(open.days?.length ? open.days : [{label: undefined, stops: open.stops}]).map((day, i) => ( +
    + {day.label &&

    {day.label}

    } +
      + {(day.stops ?? []).map((stop, j) => ( +
    • + {stop.name} + {(stop.note || stop.minutes) && ( + + {[stop.minutes && `${stop.minutes}분 머뭅니다`, stop.note].filter(Boolean).join(' · ')} + + )} +
    • + ))} +
    +
    + ))} + + )} +
    +
    + ); +} diff --git a/solution/site/src/layouts/bigtype/Location.tsx b/solution/site/src/layouts/bigtype/Location.tsx new file mode 100644 index 0000000..05ea8c1 --- /dev/null +++ b/solution/site/src/layouts/bigtype/Location.tsx @@ -0,0 +1,74 @@ +import {useState} from 'react'; +import {useSite} from '@site/lib/site-context'; +import {essentialRows} from '@site/lib/derive'; +import {kakaoDirectionsUrl, naverDirectionsUrl, osmEmbedUrl} from '@site/lib/format'; +import {Idx} from './SectionHead'; + +export function Location() { + const payload = useSite(); + const {place, routes} = payload; + const [copied, setCopied] = useState(false); + const address = place.roadAddress ?? place.address; + if (!address) return null; + const {latitude: lat, longitude: lng} = place; + const parking = essentialRows(payload).find((row) => row.key === 'parking'); + const lot = place.roadAddress && place.address && place.address !== place.roadAddress ? place.address : undefined; + + const copy = () => { + navigator.clipboard.writeText(address).then( + () => { + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + }, + () => setCopied(false), + ); + }; + + return ( +
    + + {lat != null && lng != null ? ( +