diff --git a/.env.example b/.env.example
index 37b4ae8..04a64c3 100644
--- a/.env.example
+++ b/.env.example
@@ -120,6 +120,22 @@ KAKAO_LINK_MAX_ATTEMPTS=5
# ★ 백엔드(aud 대조)와 프론트(버튼)가 **같은 값**을 써야 한다 — compose 가 이 하나를
# VITE_GOOGLE_CLIENT_ID 로 흘려보낸다. 두 곳에 따로 적지 않는다.
# ★ 바꾸면 프론트를 다시 구워야 한다: ./deploy.sh solution-site
+# 카카오 로그인. 비우면 카카오 로그인만 꺼진다(서버는 뜨고 버튼도 안 뜬다).
+# 카카오 개발자 콘솔 > 내 애플리케이션 > 앱 키(REST API 키) · 카카오 로그인 > 보안(client_secret)
+# ★★ **챗봇에 물린 앱과 같은 앱**이어야 한다 — 회원번호가 챗봇 웹훅의 appUserId 와 같은
+# 값이라, 그래야 카톡 채널 발화자와 로그인 계정이 자동으로 이어진다(6자리 코드 불필요).
+# 앱이 다르면 로그인은 되는데 매칭만 조용히 안 된다. 그래서 아래 값은 KAKAO_BOT_REST_API_KEY
+# 와 같은 값이고, 어긋나면 서버가 로그에 경고를 남긴다(services/external/kakao_identity.py).
+KAKAO_LOGIN_REST_API_KEY=
+# ★ 이건 비밀이다. 그래서 프론트가 토큰을 받는 구조를 못 쓰고 **인가 코드를 서버가 교환**한다.
+KAKAO_LOGIN_CLIENT_SECRET=
+# ★ 콘솔의 [카카오 로그인 > Redirect URI] 에 등록한 값과 **글자 하나까지** 같아야 한다.
+# 다르면 KOE006 이고 화면에는 그냥 "로그인 실패" 로만 보인다.
+# ★ 프론트(VITE_KAKAO_REDIRECT_URI)와 같은 값이어야 한다 — compose 가 흘려보낸다.
+# 바꾸면 프론트를 다시 구워야 한다: ./deploy.sh solution-site
+KAKAO_LOGIN_REDIRECT_URI=
+# ★ 어드민 키는 적지 않는다. 계정을 지우는 것까지 되는 키인데 로그인에는 쓰이지 않는다.
+
GOOGLE_CLIENT_ID=
# CORS 허용 오리진. 쉼표로 여럿.
diff --git a/AGENTS.md b/AGENTS.md
index 8643398..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) |
@@ -19,6 +18,10 @@
| 장애가 나면 누가·어떻게 아나 | [docs/ALERTS.md](docs/ALERTS.md) |
| **미니 블로그**(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/docker-compose.yml b/docker-compose.yml
index d37cc6d..514e6bf 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -24,6 +24,12 @@ x-common-env: &common-env
# ★ 프론트(VITE_GOOGLE_CLIENT_ID)와 같은 값이어야 한다 — 백엔드는 이 값으로 구글 토큰의
# 수신자(aud)를 대조한다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다.
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
+ # ★ 카카오 로그인은 시크릿 때문에 **서버가 인가 코드를 교환**한다 — 프론트에는 REST 키와
+ # 리다이렉트 주소만 간다(둘 다 공개값이다). 시크릿은 여기서 밖으로 나가지 않는다.
+ # ★★ KAKAO_BOT_REST_API_KEY 와 **같은 값**이어야 카톡 채널 발화자와 계정이 이어진다.
+ KAKAO_LOGIN_REST_API_KEY: ${KAKAO_LOGIN_REST_API_KEY:-}
+ KAKAO_LOGIN_CLIENT_SECRET: ${KAKAO_LOGIN_CLIENT_SECRET:-}
+ KAKAO_LOGIN_REDIRECT_URI: ${KAKAO_LOGIN_REDIRECT_URI:-}
# ★ 프론트(VITE_PUBLISH_HOST)와 같은 값이어야 한다. canonical·og:url·sitemap 이 전부 이걸 쓴다.
# ★ 기본값은 localhost 다. 운영 도메인을 기본으로 두면 .env 를 안 채운 로컬 빌드가
# 조용히 운영 주소를 번들에 굽는다(실측 2026-09-03: 로컬 링크가 킹서버로 갔다).
@@ -190,6 +196,9 @@ services:
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
# 백엔드와 같은 값을 흘려보낸다(루트 .env 가 단일 출처).
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
+ # 인가 URL 을 만드는 데 쓴다. 시크릿은 넘기지 않는다 — 교환은 백엔드가 한다.
+ VITE_KAKAO_REST_API_KEY: ${KAKAO_LOGIN_REST_API_KEY:-}
+ VITE_KAKAO_REDIRECT_URI: ${KAKAO_LOGIN_REDIRECT_URI:-}
volumes:
- ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json
@@ -260,6 +269,9 @@ services:
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
# 비어 있으면 구글 로그인 버튼이 안 뜬다. 백엔드 GOOGLE_CLIENT_ID 와 같은 값이다.
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
+ # 비어 있으면 카카오 로그인 버튼이 안 뜬다. 인가 URL 만 만든다 — 시크릿은 여기 없다.
+ VITE_KAKAO_REST_API_KEY: ${KAKAO_LOGIN_REST_API_KEY:-}
+ VITE_KAKAO_REDIRECT_URI: ${KAKAO_LOGIN_REDIRECT_URI:-}
# ★ VITE_AUTO_LOGIN_ID·PW 는 여기 없다 — nginx/Dockerfile 이 그 ARG 를 아예 안 받는다.
# 자동 로그인이 필요하면 solution-frontend(--profile dev)를 쓴다.
image: o2o-web4ai-solution-site
diff --git a/docs/AGENT.md b/docs/AGENT.md
index 637fd5b..296d1dd 100644
--- a/docs/AGENT.md
+++ b/docs/AGENT.md
@@ -1,5 +1,8 @@
# 사장님 에이전트 — 신원 연결 · 도구 · 런타임
+> 사장님이 말로 **무엇을 시킬 수 있는지**(운영자·CS 용 목록)는 [AGENT_GUIDE.md](AGENT_GUIDE.md).
+> 이 문서는 **왜 그렇게 동작하는지**를 다룬다.
+
사장님이 말로 사이트를 운영하는 것이 목표다 — 내용 고치기, 사진 내리기, 발행, SNS 게재까지.
**에이전트는 카카오톡 안에 있지 않다.** 카톡은 입구 하나이고, 같은 에이전트가 빌더 화면에도
붙는다. 그래야 채널·챗봇 심사 전에 전부 검증된다.
@@ -35,6 +38,21 @@
그 `owner_user_id` 를 줄 근거가 없다 — **연결 절차가 없으면 채널 진입점만 소유자 범위 밖에
놓이고, 채널에 말을 건 아무나가 남의 가게를 고친다.**
+## 카카오 로그인으로 가입했으면 — 코드가 필요 없다 (2026-09-30)
+
+★★ 챗봇 웹훅의 `user.properties.appUserId` 는 **카카오 로그인의 회원번호와 같은 값**이다
+ (카카오 공식 문서, 봇에 앱키가 물려 있을 때만 온다). 그래서 카카오로 로그인만 해 두면
+ 채널에 말을 거는 순간 누구인지 알 수 있다 — `kakao_link_service.link_by_app_user_id`.
+
+★ **같은 앱이어야 한다.** 로그인 앱과 봇에 물린 앱이 다르면 회원번호가 달라서
+ **로그인은 되는데 매칭만 조용히 안 된다**. 증상이 안 보이는 종류다.
+
+★ **코드 경로를 지우지 않는다.** id/pw·구글로 가입한 사장님에게는 `appUserId` 가 없고,
+ 봇에 앱키가 안 물린 환경에서는 그 값 자체가 오지 않는다 — 그때 유일한 길이다.
+
+★ 그 카톡이 **이미 다른 사장님**에게 묶여 있으면 잇지 않는다. 조용히 빼앗으면 앞사람이
+ 남의 가게를 보게 된다.
+
## 절차 — 사장님은 두 번 누른다
1. `/sites` **내 사이트** 화면의 `카카오톡으로 관리 · 채널 연결` 카드 → **[카카오톡 연결]**
@@ -137,7 +155,7 @@ services/fact_service.py · site_service.py ★ 게이트가 사는 곳
### 페이지 구성 (2026-09-28)
-"후기 빼줘" · "사진 갤러리 맨 위로" 처럼 **화면 구성**을 바꾼다. 구성은 `sites.theme.sections`
+"날씨 빼줘" · "사진 갤러리 맨 위로" 처럼 **화면 구성**을 바꾼다. 구성은 `sites.theme.sections`
배열 하나이고, **배열 순서가 곧 발행본의 섹션 순서**다.
★ 목록은 `site_payload._sections` 를 **그대로 쓴다** — 발행본이 쓰는 바로 그 함수다.
@@ -152,6 +170,40 @@ services/fact_service.py · site_service.py ★ 게이트가 사는 곳
★ **`sections` 만 갈아끼운다.** theme 을 통째로 새로 쓰면 사장님이 고른 색·서체가 말없이 사라진다.
+#### 옮기기 · 숨기기 (2026-09-29)
+
+| 말 | `move_section` 인자 | 세는 기준 |
+|---|---|---|
+| "갤러리 맨 위로 / 맨 아래로" | `where=맨 위 · 맨 아래` | 배열 |
+| "갤러리를 소개 앞으로 / 다음으로" | `where=앞 · 뒤`, `to=소개` | 배열 |
+| "갤러리 한 칸 위로 / 두 칸 아래로" | `where=위로 · 아래로`, `count=1 · 두` | **보이는 순서** |
+| "날씨 세 번째로" | `where=번째`, `count=3` | **보이는 순서** |
+| "소개랑 갤러리 자리 바꿔줘" | `where=바꾸기`, `to=사진 갤러리` | 배열 |
+
+`where` 가 비면 예전 표기로 읽는다(`to` 에 '맨 위' · '맨 아래' · 그 뒤에 올 이름).
+★ `where` 가 **왔는데 못 알아들으면** 예전 표기로 넘기지 않고 되묻는다 — `to` 만 보고 '다음으로' 옮기면
+"소개 앞쪽으로" 가 소개 뒤로 간다. 섹션을 여럿 적을 때 구분은 쉼표뿐이다(`·` 는 이름에 들어 있다).
+
+★ **히어로·SNS 게시글은 옮기지 않는다**(`tools.PINNED`). 발행본(`site/src/pages/HomePage.tsx`)이
+배열 순서와 상관없이 히어로를 늘 맨 위에, SNS 를 늘 맨 아래에 그린다 — 옮기게 두면 "옮겼습니다"
+라고 말하는데 화면은 그대로다. 그 둘을 기준으로 삼는 것도 같다: 히어로 **다음**은 맨 위, SNS **앞**은
+맨 아래로 읽고, 히어로 앞 · SNS 뒤 · 그 둘과 자리 바꾸기는 거절한다. 기본 정보·오시는 길은 잠겼어도
+순서대로 그려지므로 옮길 수 있다. 프롬프트에도 `[항상 맨 위]` · `[항상 맨 아래]` 로 싣는다.
+
+★ **'한 칸' · 'N번째' 는 보이는 순서로 센다**(켜진 것, 히어로·SNS 제외). 꺼진 부분은 화면에 없어서,
+배열로 세면 꺼진 부분과 자리만 바꾸고 화면은 그대로인 이동이 생긴다. 그래서 꺼진 부분은 칸으로
+옮기지 않고(켠 뒤 말하거나 '소개 다음으로' 처럼), 없는 순번(1~보이는 수 밖)은 추측하지 않고 거절한다.
+`list_sections` 의 번호가 이 순서다 — 목록에서 본 번호로 말했는데 다른 자리로 가면 고장난 줄 안다.
+이미 그 자리면 `Unchanged` 다(재발행을 권하지 않는다).
+
+★ **숨기기는 끄기다 — 지우지 않는다.** 에디터에도 빼는 기능이 없고, `_sections` 가 저장값에 없는
+기본 섹션을 켜서 끝에 다시 붙인다. `toggle_section` 은 `name` 에 쉼표로 여럿을 받는다("사진 갤러리, 날씨").
+★ 여럿 중 **하나라도** 못 찾거나 끌 수 없으면 **아무것도 바꾸지 않는다** — 일부만 끄면 사장님은 무엇이
+꺼졌는지 다시 확인해야 한다. 하나의 요청이라 상한(5개)도 하나로 센다.
+`list_sections` 는 꺼진 것을 따로 모아 보여 주고, `only=꺼진` 이면 그것만 답한다("숨긴 거 뭐 있어").
+
+⚠️ 이용 후기 · 엽서 쓰기는 섹션 목록에 없다 — 발행본이 늘 그린다. "후기 빼줘" 는 "못 찾았어요" 로 끝난다.
+
### 사진 (2026-09-28)
"객실 사진 내려줘" · "대표 사진 수영장으로 바꿔줘". 지목은 Vision 이 만든 **라벨·alt** 로 한다.
@@ -170,6 +222,11 @@ services/fact_service.py · site_service.py ★ 게이트가 사는 곳
안 만들어 둔 것이고(DECISIONS 5-3), 도구가 생기면 **그 결정을 코드가 먼저 풀어 버린다.**
테스트가 레지스트리에 `upload`·`replace` 가 없는지 실제로 검사한다.
+★ **대표·목록은 '나가는 사진' 기준이다**(2026-09-29). 내린 사진(`publishable` 아님)의 순서만
+당기면 "바꿨습니다" 라고 말하는데 발행본의 대표는 그대로다. 그래서 내린 사진은 대표로 지정하지
+않고, 이미 내린 사진을 또 내리라면 "이미 안 나가고 있어요" 로 답한다. 프롬프트에는 나가는 사진만,
+대표를 맨 앞에 싣는다. 이름이 정확히 맞는 한 장이 있으면 부분 일치가 여럿이어도 그걸 고른다.
+
★ 서버 엔드포인트(`POST .../media/{id}/hide` · `/primary`)도 함께 열었다 — 에이전트 전용
뒷문을 만들면 빌더 화면이 그 기능을 못 쓰고, 나중에 붙일 때 로직이 두 벌이 된다.
@@ -190,6 +247,28 @@ services/fact_service.py · site_service.py ★ 게이트가 사는 곳
★ **모호하면 실행하지 않고 되묻는다.** 티오더가 "유사한 메뉴가 2개 이상이면 후보 목록을 제시"
로 푼 문제와 같다 — 추측으로 고르면 사장님이 그걸 못 알아채고 넘어간다.
+### 값 형식 (2026-09-29)
+
+저장 형식은 수집 어댑터와 같다 — bool `true`/`false` · time `HH:MM` · number 숫자만.
+렌더러(`shared/src/lib/facts.ts` `factBool`)는 `'true'` 만 참으로 읽어서, "가능" 으로 저장하면
+화면에는 "가능" 이 뜨는데 구조화 데이터는 거짓이 된다 — 빌드도 성공하는 조용한 틀림이다.
+
+| 형식 | 받는 말 → 저장값 | 되묻는 경우 |
+|---|---|---|
+| bool | 가능·돼요·있음 → `true`, 불가·안 돼요·없음 → `false` | "소형견만" 처럼 가능·불가가 아닌 말 |
+| time | `15:00` · 오후/낮 3시 · 15시 30분 · 3시 반 → `HH:MM`, 밤 12시 → `00:00` | **"3시"(오전·오후 모름)** · 25:00 |
+| number | 2만원 → `20000` · 2만 5천원 → `25000` · 만원 → `10000` · 20,000원 · 무료 → `0` | "문의" · "만 오천원" 처럼 숫자가 아닌 말 |
+
+두 겹이다 — 프롬프트가 항목마다 형식을 싣고(`key: 이름 (형식)`), 도구가 다시 맞춘다
+(`tools._normalize`). ★ 모델만 믿지 않는다: 스키마를 어기고 `true` 를 불리언으로, 인자를
+배열로 보낼 때도 있다(`_arg` · `runtime._args` 가 받는다).
+사장님께 알리는 문장은 저장값이 아니라 발행본의 말로 한다 — "반려동물 동반 을(를) 가능 로 바꿨습니다".
+
+⚠️ 이 검증은 **대화 경로에만** 있다. `fact_service.upsert_fact` 는 형식을 보지 않는다(빌더·수집기 공용).
+
+★ **켤지 끌지 모르면 끄지 않는다.** 스키마가 모든 인자를 필수로 받아 모델이 `enabled` 를 `""` 로
+채울 수 있다. 예전에는 모르는 말을 '끄기' 로 읽어서 "후기 다시 보여줘" 가 후기를 껐다.
+
## 한 발화에 여러 가지 (2026-09-28)
"체크인 3시로 바꾸고 후기 섹션도 빼줘" 처럼 한 번에 시킨다. 응답 스키마가 `actions` **배열**이고
@@ -218,6 +297,39 @@ SEMI(publish) ★ 거기서 멈춘다 — 앞서 한 일을 함께 말하
★ **상한 5개.** 무한정 허용하면 "다 지워줘" 한 마디에 연쇄로 실행된다.
+### 못 한 것·남은 것·겹친 것 (2026-09-29)
+
+★ **말없이 빠뜨리지 않는다.** 되는 것만 하고 입을 다물면 사장님은 전부 된 줄 안다.
+
+| 경우 | 답 |
+|---|---|
+| 도구로 할 수 없는 요청이 섞임 ("…전화번호도 바꿔줘") | `'전화번호 변경' 은(는) 대화로는 아직 할 수 없어요.` |
+| 모델이 지어낸 도구 | `알아듣지 못한 요청 1가지는 하지 않았어요.` |
+| 실패·발행에서 멈춤 — 그 뒤의 요청 | `소개 옮기기, 체크아웃 시간 변경 은(는) 아직 하지 않았어요.` |
+
+→ 응답 스키마의 `skipped` 칸은 **이름만** 받는다("전화번호 변경"). 문장은 런타임이 틀에 끼워 만든다 —
+ 문장을 받으면 모델이 "했습니다" 라고 쓸 자리가 생긴다. 이 칸이 생기기 전에는 `message` 가
+ `actions` 가 있으면 버려져서, 모델이 "전화번호는 못 해요" 라고 써도 사장님께 닿지 않았다.
+→ 남은 요청의 이름도 도구가 만든다(`Tool.title` · `Tool.describe` → `tools.describe_action`).
+→ ★ 발행에서 멈출 때 **묻는 말은 맨 끝**에 선다. 그 뒤에 다른 말이 붙으면 [네, 해주세요] 가 무엇에
+ 대한 답인지 흐려진다. 확인을 눌러도 발행 하나만 돈다 — 그래서 남은 것을 확인 **전에** 알린다.
+
+★ **같은 대상을 두 번 시키면 마지막 하나만 한다**("체크인 3시… 아니 4시로"). 둘 다 하면 문구에
+두 값이 함께 서서 어느 쪽이 남았는지 모른다. 같은 대상인지는 `Tool.target` 이 정한다(set_fact 는
+`key`, 켜기·끄기와 사진 내리기는 `name`, 대표 사진·발행은 하나뿐). 자리는 마지막 것의 자리이고,
+**상한을 세기 전에** 합친다 — 고쳐 말한 것까지 세면 할 수 있는 일이 잘린다.
+★ **옮기기는 합치지 않는다**(인자까지 똑같을 때만). 차례가 뜻이다 — "날씨 맨 위로, 그리고 한 칸 아래로"
+를 마지막 하나로 합치면 두 번째 자리가 아니라 원래 자리에서 한 칸 아래가 된다.
+
+★ **바뀐 것이 없으면 재발행을 권하지 않는다.** "이미 켜져 있어요" 에 "다시 발행해야 해요" 가 붙으면
+무언가 바뀐 줄 안다. 도구가 `Unchanged` 로 돌려주면 런타임은 `done=False` 로 두고, 카톡은 발행
+대기를 걸지 않는다.
+
+★ **지금 고칠 수 있는 가게는 하나다.** 프롬프트에 그 가게만 실린다. 카톡에서 **다른 내 가게 이름**이
+발화에 나오면 모델을 부르기 전에 끊고 고르게 한다(`channel._other_named`) — 그대로 넘기면 지금 가게가
+바뀌고 사장님은 다른 가게가 바뀐 줄 안다. 기억한 가게(`current_place_id`)가 목록에 없으면 비우고
+목록을 보여 준다.
+
## 확인(SEMI) 한 바퀴
1. 발화 → 런타임이 `publish` 를 고른다 → **실행하지 않고** `needs_confirm=true` + 확인 문구
diff --git a/docs/AGENT_GUIDE.md b/docs/AGENT_GUIDE.md
new file mode 100644
index 0000000..30682a5
--- /dev/null
+++ b/docs/AGENT_GUIDE.md
@@ -0,0 +1,185 @@
+# 사장님 에이전트 — 말로 할 수 있는 일
+
+> 기준: 2026-09-30 · `fix/agent-multi-action` (`091d5d9`)
+> 카카오톡 채널과 빌더 대화창([말로 고치기])은 같은 에이전트다 — 아래는 둘 다에 해당한다.
+> 카카오톡에서만 해당하는 것은 **(카톡)** 으로 표시한다.
+
+운영자·CS 가 "사장님이 말로 무엇을 시킬 수 있나" 를 확인하는 목록이다. **왜 그렇게 동작하는지**
+(등급 · 게이트 · 조용히 틀리는 함정)는 [AGENT.md](AGENT.md)가 단일 출처다 — 여기에 옮겨 적지 않는다.
+도구를 바꾸는 커밋에서 이 파일도 같이 고친다.
+
+---
+
+## 카카오톡 연결 · 가게 선택 (카톡)
+
+동작 : 빌더 [내 사이트]에서 받은 6자리 코드를 카카오톡 채널에 보내기
+행동 : 계정을 연결하고 관리 중인 홈페이지 목록과 발행 여부를 보여 줌 (코드는 10분간 유효, 1회만 사용 가능)
+
+동작 : 가게 이름 버튼 누르기 또는 가게 이름 그대로 보내기
+행동 : 그 가게를 대화 대상으로 기억함 (가게가 하나면 자동 선택)
+
+동작 : "목록", "가게 바꿔줘", "다른 가게"
+행동 : 언제든 홈페이지 목록으로 돌아가 다시 고르게 함
+
+동작 : 지금 가게가 아닌 다른 내 가게 이름을 말하기 ("둘째가게 휴무 바꿔줘")
+행동 : 실행하지 않고 "먼저 골라 주세요"라고 안내 (지금 가게가 잘못 바뀌는 것을 막음)
+
+---
+
+## 업종별 수정 가능 항목
+
+> 항목 목록의 원본은 `solution/backend/common/category_schema/resources/*.json` 의 `scope: "place"` 필드다.
+> 객실·메뉴·시술 단위(`scope: "unit"`) 값은 대화로 고칠 수 없다.
+
+숙박
+체크인, 체크아웃, 취소·환불 규정, 취사, 반려동물, 흡연, 인원 추가 요금, 바비큐 이용·이용료, 프런트 운영시간, 주차 가능·주차 대수, 와이파이, 조식, 유아용품, 픽업, 부대시설, 객실 수, 수용 인원, 규모, 숙소 소개
+
+카페
+영업시간, 휴무일, 브레이크타임, 라스트오더, 반려동물, 아동, 주차, 무료 주차 시간, 장시간 이용, 결제 수단, 휠체어, 좌석 수, 와이파이, 콘센트, 테라스, 테이크아웃, 배달, 대표 메뉴, 가격대, 카페 소개
+
+음식점
+영업시간, 휴무일, 브레이크타임, 라스트오더, 예약 필수, 예약 방법, 반려동물, 아동, 콜키지, 룸·별실, 단체석 최대 인원, 주차, 주차 대수, 포장, 배달, 결제 수단, 대표 메뉴, 가격대, 휠체어, 가게 소개
+
+피부과·성형외과
+진료시간, 휴진일, 진료과목, 의료진, 예약 필수, 예약 방법, 상담료, 보험, 취소 규정, 야간·주말 진료, 외국어 상담, 주차, 휠체어, 병원 소개
+
+---
+
+## 가게 정보 조회 · 수정
+
+동작 : "지금 저장된 정보 보여줘", "주차 정보 뭐로 돼 있어?"
+행동 : 저장된 값을 최대 20개까지 보여 줌 (키워드를 말하면 그 항목만)
+
+동작 : "체크인 오후 3시로 바꿔줘", "체크인 15시 30분", "체크인 3시 반"
+행동 : 시각으로 저장 (15:00 / 15:30). "3시"처럼 오전인지 오후인지 모르면 되물음
+
+동작 : "반려동물 이제 돼요", "흡연 안 돼요"
+행동 : 가능 / 불가로 저장. "소형견만"처럼 가능·불가로 볼 수 없는 말이면 되물음
+
+동작 : "추가 인원 2만원", "바비큐 2만 5천원", "바비큐 무료"
+행동 : 숫자로 저장 (20000 / 25000 / 0). "문의"처럼 숫자가 아니면 되물음
+
+동작 : "숙소 소개 ○○로 바꿔줘"
+행동 : 말한 문장을 그대로 저장
+
+동작 : "객실 요금 바꿔줘", "메뉴 가격 바꿔줘"
+행동 : 객실·메뉴별 값이라 "대화로는 아직 고칠 수 없어요"로 안내
+
+> 고친 값은 곧바로 저장되지만 **사이트에는 다시 발행해야 반영된다.** 답에 재발행 안내가 붙는다.
+
+---
+
+## 섹션 조회
+
+동작 : "홈페이지 구성 보여줘"
+행동 : 보이는 순서대로 번호를 붙여 보여 주고, 꺼진 섹션은 따로 모아 보여 줌 (히어로는 "항상 맨 위", SNS는 "항상 맨 아래"로 표시)
+
+동작 : "숨긴 거 뭐 있어?"
+행동 : 꺼져 있는 섹션만 보여 줌
+
+---
+
+## 섹션 켜기 · 끄기(숨기기)
+
+동작 : "날씨 빼줘", "영상 숨겨줘"
+행동 : 섹션을 삭제하지 않고 끔 (언제든 다시 켤 수 있음)
+
+동작 : "갤러리랑 날씨 숨겨줘"
+행동 : 여러 섹션을 한 번에 끔 (하나라도 못 찾거나 끌 수 없으면 아무것도 바꾸지 않고 어느 것이 문제인지 안내)
+
+동작 : "영상 다시 켜줘"
+행동 : 꺼진 섹션을 다시 켬
+
+동작 : "히어로 빼줘", "기본 정보 빼줘", "오시는 길 빼줘"
+행동 : 꼭 있어야 하는 섹션이라 "끌 수 없어요"로 안내
+
+동작 : 이미 꺼진 섹션을 또 끄라고 하기
+행동 : "이미 꺼져 있어요"로 안내하고 재발행을 권하지 않음
+
+---
+
+## 섹션 옮기는 방법
+
+동작 : "갤러리 맨 위로", "갤러리 맨 아래로"
+행동 : 해당 섹션을 맨 앞 / 맨 뒤로 이동
+
+동작 : "갤러리를 소개 앞으로", "갤러리를 소개 다음으로"
+행동 : 지정한 섹션의 바로 앞 / 바로 뒤로 이동
+
+동작 : "갤러리 한 칸 위로", "소개 두 칸 아래로"
+행동 : 말한 칸 수만큼 이동 (보이는 순서 기준, 꺼진 섹션은 칸 이동 불가)
+
+동작 : "날씨 세 번째로", "날씨 첫 번째로"
+행동 : 구성 목록의 해당 번호 자리로 이동 (없는 번호면 되물음)
+
+동작 : "소개랑 갤러리 자리 바꿔줘"
+행동 : 두 섹션의 위치를 맞바꿈
+
+동작 : "히어로 맨 아래로", "SNS 게시글 맨 위로"
+행동 : 항상 맨 위 / 맨 아래에 고정된 섹션이라 "옮길 수 없어요"로 안내
+
+동작 : 이미 그 자리에 있는 섹션을 옮기라고 하기
+행동 : "이미 맨 위에 있어요"로 안내하고 재발행을 권하지 않음
+
+---
+
+## 사진 조회 · 수정
+
+동작 : "사진 목록 보여줘"
+행동 : 최대 15장까지 보여 줌 (대표 사진과 숨긴 사진을 표시)
+
+동작 : "대표 사진 수영장으로 바꿔줘"
+행동 : 해당 사진을 대표로 지정 (객실·메뉴 전용 사진, 숨긴 사진은 불가)
+
+동작 : "객실 사진 내려줘"
+행동 : 사진을 삭제하지 않고 숨김 (이미 숨겼으면 "이미 안 나가고 있어요"로 안내)
+
+동작 : 이름이 비슷한 사진이 여러 장일 때 ("객실 A", "객실 B" 중 "객실")
+행동 : 추측하지 않고 되물음 (이름이 정확히 맞는 사진이 한 장이면 그 사진을 선택)
+
+> 사진은 Vision 이 붙인 라벨(예: "외관", "수영장")로 지목한다.
+
+---
+
+## 발행
+
+동작 : "홈페이지 발행됐어?"
+행동 : 발행 여부, 주소, 마지막 발행 시각을 알려 줌
+
+동작 : "발행해줘"
+행동 : 바로 실행하지 않고 [네, 해주세요] / [아니요]로 한 번 확인한 뒤 발행
+
+동작 : 정보나 섹션을 고친 직후 (카톡)
+행동 : "다시 발행해야 반영돼요" 안내와 [네, 발행해주세요] 버튼을 붙임 (3분 안에 누르면 발행, 3분이 지나거나 다른 말을 하면 취소)
+
+---
+
+## 한 번에 여러 요청
+
+동작 : "체크인 3시로 바꾸고 날씨 빼줘"
+행동 : 시킨 순서대로 처리 (한 번에 최대 5가지, 재발행 안내는 한 번만)
+
+동작 : "체크인 오후 3시로 바꾸고 전화번호도 바꿔줘"
+행동 : 되는 것은 처리하고, 안 되는 것은 "'전화번호 변경' 은(는) 대화로는 아직 할 수 없어요"로 안내
+
+동작 : 중간에 하나가 실패함
+행동 : 거기서 멈추고 앞의 변경은 유지. 남은 요청은 "아직 하지 않았어요"로 안내
+
+동작 : "갤러리 빼고, 발행하고, 체크인 오후 3시로"
+행동 : 발행 앞까지만 처리하고 발행 확인을 받음 (발행 뒤에 남은 요청은 확인 전에 미리 안내)
+
+동작 : "체크인 3시… 아니 4시로"
+행동 : 마지막 값 하나만 처리 (옮기기는 합치지 않고 시킨 순서대로 모두 실행)
+
+---
+
+## 대화로 할 수 없는 것
+
+- 객실·메뉴·시술별 값 (요금, 인원 등)
+- 가게 이름·주소·전화번호
+- 템플릿·색·서체
+- 사진 올리기·교체, 섹션 새로 추가, 가게·섹션·사진 완전 삭제
+- 이용 후기·엽서 쓰기 끄기 (섹션 목록에 없고 항상 표시됨)
+- 여러 가게 동시 수정, SNS 게시
+
+위 기능을 요청하면 할 수 없다고 안내하고, 다른 요청과 섞여 있으면 되는 것만 처리한다.
diff --git a/docs/ALERTS.md b/docs/ALERTS.md
index dc25ba0..f062796 100644
--- a/docs/ALERTS.md
+++ b/docs/ALERTS.md
@@ -13,6 +13,7 @@
| `partial_failure` | 노래 등 곁가지 생성 실패(발행 자체는 계속) | `song_failed:{place_id}` |
| `queue_stuck` | dead-letter 누적·좀비 실행·PENDING 30분 이상 정체 | `queue_health` |
| `recovery` | 위 dedupe_key 가 다음 정상 상태에서 풀릴 때 한 번 | 없음(매번 새 행) |
+| `activity` | 채널 크롤링 실패 · 사진 분석 일부 실패(`services/activity_feed.py`) | 없음(매번 보낸다) |
★ **게이트 반려는 알리지 않는다.** 사장님이 fact 를 안 채웠거나 고유 콘텐츠가 없어서 막힌 건
운영자가 손댈 일이 아니다 — `build_service._fail(reason, gate=None)` 일 때만 `build_failed`.
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,21 @@ 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) {
+/** 사진을 **우리 오리진으로 옮긴다.** */
+interface MediaMirrorStats {
+ total: number;
+ fetched: number;
+ cached: number;
+ failed: number;
+}
+
+async function mirrorMedia(payload: SitePayload, siteDir: string): Promise {
const items = (payload.media ?? []).filter((item) => /^https?:\/\//i.test(item.url ?? ''));
- if (items.length === 0) return;
+ const stats: MediaMirrorStats = {total: items.length, fetched: 0, cached: 0, failed: 0};
+ if (items.length === 0) return stats;
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,13 +277,13 @@ 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) {
wanted.add(hit);
item.url = joinUrl(publicBase, `${MEDIA_DIR}/${hit}`);
+ stats.cached += 1;
continue;
}
@@ -424,16 +306,18 @@ async function mirrorMedia(payload: SitePayload, siteDir: string) {
item.url = joinUrl(publicBase, `${MEDIA_DIR}/${name}`);
fetched += 1;
} catch (ex) {
+ stats.failed += 1;
console.warn(` ! 사진을 못 받았습니다(원래 주소를 씁니다): ${origin} — ${ex}`);
}
}
- // 지난 발행의 사진은 치운다 — 노래(copySongs)와 같은 이유다. 안 치우면 사장님이 사진을
- // 바꿀 때마다 쌓이고, Azure 발행 때 그대로 같이 올라간다.
+ // 지난 발행의 사진은 치운다 — 노래(copySongs)와 같은 이유다.
for (const name of readdirSync(dir)) {
if (!wanted.has(name)) rmSync(join(dir, name), {force: true});
}
if (fetched > 0) console.log(` 사진 ${fetched}장을 내려받았습니다 (총 ${wanted.size}장)`);
+ stats.fetched = fetched;
+ return stats;
}
/** 이 슬러그·버전이 실제로 구워지는 자리 — `out/versions///`. */
@@ -450,18 +334,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 +349,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 +362,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 +401,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 +408,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 +431,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 +446,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 +475,7 @@ function prerenderSite(
' ',
head,
' ',
- /* ★ class="site" 는 장식이 아니다 — 시안 토큰·유틸(shared/styles/site.css)이 이 클래스
- 안에서만 산다. 빼면 유동 타이포와 .shell·.h2 가 통째로 죽어 글자 크기가 본문으로 떨어진다.
- 빌더 캔버스는 같은 규칙을 `.site-canvas` 로 받는다. */
+ /* class="site" 는 장식이 아니다 — 시안 토큰·유틸(shared/styles/site.css)이 이 클래스 안에서만 산다. */
' ',
`
${appHtml}
`,
` `,
@@ -652,7 +484,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 +494,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 +526,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 +569,7 @@ function countUniqueContent(payload: SitePayload): number {
return count;
}
-/**
- * 렌더 결과 보고서. payload 파일 옆(`.status/.json`)에 쓴다.
- *
- * ★ 왜 필요한가
- * 지금까지 프리렌더는 성공하든 실패하든 아무것도 남기지 않았다. 빌드가 깨지면
- * DB 에는 "발행됨"으로 남고 페이지는 없는 상태가 되는데, 아무도 그걸 모른다.
- * 백엔드에 마운트된 유일한 디렉토리가 payload 디렉토리라 보고서도 그 안에 쓴다.
- *
- * ★ 임시파일 → rename. 백엔드가 반쯤 쓰인 JSON 을 읽지 않게 한다.
- */
+/** 렌더 결과 보고서. */
interface RenderReport {
schemaVersion: 1;
slug: string;
@@ -790,9 +582,10 @@ interface RenderReport {
bundle: string;
uniqueContentCount: number | null;
jsonld: unknown[] | null;
- /** ★ 절대규칙 3 위반 목록. 비어야 발행 가능하다 — 백엔드 게이트가 이걸 본다. */
+ /** 절대규칙 3 위반 목록. */
mismatches: string[];
error: string | null;
+ media?: MediaMirrorStats;
}
function writeReport(payloadFile: string, report: RenderReport) {
@@ -804,35 +597,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 +623,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 +675,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 +691,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 +709,7 @@ function pruneAssets(assetsDir: string, current: string[], referenced: Set) {
const assetsSrc = join(CLIENT_DIR, 'assets');
if (existsSync(assetsSrc)) {
@@ -1002,34 +739,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 +748,7 @@ function writePreviewShell(outRoot: string, assets: {script: string; css: string
' ',
' ',
' ',
- // ★ 미리보기는 색인 대상이 아니다. 발행 전 값이라 검색에 걸리면 안 된다.
+ // 미리보기는 색인 대상이 아니다.
' ',
' 미리보기',
...assets.css.map((file) => ` `),
@@ -1054,20 +765,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 +787,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 +821,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 +848,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 +864,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 +905,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 +921,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('저장 버전 불일치');
@@ -1270,13 +935,12 @@ async function main() {
}
continue;
}
- await mirrorMedia(entry.payload, stagingDir);
+ const media = await mirrorMedia(entry.payload, stagingDir);
const result = prerenderSite(entry.payload, args.out, assets, referenced);
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,22 +961,20 @@ async function main() {
siteVersion: payload.site.version,
ok: true,
renderedAt: new Date().toISOString(),
- // 사이트당 한 장. 보고서 스키마는 백엔드가 읽으므로 필드는 남긴다.
+ // 사이트당 한 장.
routes: 1,
bundle: assets.script,
uniqueContentCount: result.uniqueContentCount,
jsonld: result.jsonld,
mismatches: [],
error: null,
+ media,
};
writeFileSync(savedReportPath, JSON.stringify({...report, origin: payload.site.origin}));
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 +985,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 (
+
- )}
-
- {/*
- 상호 띠 — 데스크톱에서는 격자 1~12칸 같은 행에 놓고 아래에 붙인다(self-end).
- 사진이 5~12칸이므로 왼쪽 4칸은 종이 위, 나머지는 사진 위를 지난다.
- ★ z-10: 격자에서 뒤에 오는 형제(왼쪽 칸)가 이 띠를 덮지 않게 한다.
- */}
-
- {/* h1 은 페이지에 하나. 가게 이름이 들어가야 "○○ 홈페이지" 질의에 잡힌다. */}
-
- {place.name}
-
-
-
- {/* 왼쪽 종이 면 — 읽는 값이 전부 여기 있다. 아래는 상호 띠가 지나가는 자리다. */}
- {/* pb-56 은 상호 띠 높이(제목 5.5rem + 여백)에서 나온 값이다 — 줄이면 글자가 겹친다. */}
-
- {tagline && (
-
- {tagline}
-
- )}
-
- {meta.length > 0 && (
-
- {meta.join(' · ')}
-
- )}
-
- {/* 버튼을 세우지 않는다 — 잡지의 규칙은 밑줄 링크다. 대신 .tap 으로 높이를 지킨다. */}
- {(booking || place.phone || contact) && (
-
- )}
-
- {/* 확인된 값만. 없는 업종에서는 칸 자체가 안 생긴다. */}
- {facts.length > 0 && (
-
- {facts.map((row) => (
-
-
{row.label}
-
{row.value}
-
- ))}
-
- )}
-
-
-
- );
-}
diff --git a/solution/site/src/layouts/editorial/Rooms.tsx b/solution/site/src/layouts/editorial/Rooms.tsx
deleted file mode 100644
index e10bc16..0000000
--- a/solution/site/src/layouts/editorial/Rooms.tsx
+++ /dev/null
@@ -1,240 +0,0 @@
-import type {ChannelLink} from '@o2o/shared';
-import {useSite} from '@site/lib/site-context';
-import {
- bookingActionLabel,
- bookingLinks,
- sectionName,
- unitSpec,
- unitViews,
- type UnitView,
-} from '@site/lib/derive';
-import {GRAIN, SectionHead} from './SectionHead';
-
-/**
- * 편집(잡지)형 객실 — 한 객실이 한 판이다.
- *
- * ★ 카드로 감싸지 않는다. 카드 격자는 '고르는 화면'이고, 잡지의 판은 '보여 주는 화면'이다.
- * 판마다 배경을 밝은 면 ↔ 어두운 면으로 교차해 넘기는 리듬을 만든다.
- * ★ `lib/ui/Section` 을 쓰지 않고 을 직접 그린다 — 판마다 배경이 갈려야 하는데
- * Section 은 섹션 하나에 바탕 하나다.
- * ★ 사진은 두 장을 위아래로 어긋나게 걸되, 세 번째부터는 아래 접지(contact sheet)로 남긴다.
- * 장식을 위해 사진을 버리면 그 alt 문장이 HTML 에서 사라진다 — 인용될 자리가 준다.
- */
-export function Rooms() {
- const payload = useSite();
- const units = unitViews(payload);
- const spec = unitSpec(payload);
- const booking = bookingLinks(payload)[0];
-
- if (units.length === 0) return null;
-
- return (
-
-
- {/* 섹션 id 는 업종마다 다르다(rooms · menu · programs) — spec.path 가 그 id 다. */}
-
-
-
- {units.map((unit, order) => (
-
- ))}
-
- );
-}
-
-function UnitSpread({
- unit,
- no,
- dark,
- booking,
- phone,
-}: {
- unit: UnitView;
- no: string;
- dark: boolean;
- booking?: ChannelLink;
- phone?: string;
-}) {
- return (
-
- {/*
- ★ 가로 홈(gap-x-10)은 xl 부터다. 390px 에서 12칸 격자에 40px 홈을 주면
- 홈만 440px 이라 컨테이너(347px)를 넘고, 페이지 전체가 77px 가로로 밀린다(실측).
- 좁은 화면에서는 어차피 전부 col-span-12 라 홈이 할 일이 없다.
- */}
-
- {/*
- 왼쪽 절반 — 번호 · 이름 · 스펙. 읽는 값은 전부 종이 위에 있다.
- ★ pr-8 은 장식이 아니다. 오른쪽 사진이 격자의 홈을 통째로 먹으므로(-ml-10)
- 이 여백이 없으면 스펙 표의 값이 사진 모서리에 붙는다(실측: 간격 0px).
- */}
-
-
-
- {no}
-
-
- {unit.name}
-
-
-
- {unit.intro && (
-
- {unit.intro}
-
- )}
-
- {/*
- ★ 요금은 한 값이 아니라 비교하는 값이다. 주중 하나만 적어 두면 손님은
- 주말이 얼마인지 모른 채 전화를 걸거나 떠난다. 안 채워진 칸은 '문의' 로 온다.
- */}
-
- {/* 요금은 객실 정보에서 내지 않는다 — 사유는 sections/UnitsSection.tsx 의 같은 자리 주석. */}
-
- {unit.rows.length > 0 && (
-
-
- {/*
- 오른쪽 절반 — 사진 두 장을 어긋나게.
- ★ -ml-10 이 격자의 홈(gap-x-10)을 그대로 먹는다. 글이 있는 칸까지 넘기지는 않는다 —
- 겹침이 읽기를 방해하면 그건 장식이 아니라 고장이다.
- ★ 음수 여백은 xl 부터다. 390px 에서는 두 장이 그냥 위아래로 선다.
- */}
-
- {/*
- ★ 폭을 64%/50% 로 잡은 건 취향이 아니라 균형이다. 82% 로 뒀더니 오른쪽 칸이
- 1120px 이 되어 왼쪽 글 아래로 600px 짜리 빈 판이 생겼다(실측) —
- 어두운 판에서는 그 빈자리가 통째로 검은 구멍으로 보인다.
- */}
- {unit.images[0] && (
-
-
-
- )}
-
- {unit.images[1] && (
-
-
-
- )}
-
-
- {/*
- 나머지 사진 — 판 아래를 가로지르는 접지(contact sheet).
- ★ 작게라도 남겨야 alt 가 HTML 에 남는다. 장식 때문에 사진을 버리면
- 그 문장이 인용될 자리도 같이 사라진다.
- */}
- {unit.images.length > 2 && (
-
- {unit.images.slice(2).map((image) => (
-
-
-
- ))}
-
- )}
-
-
- );
-}
diff --git a/solution/site/src/layouts/editorial/SectionHead.tsx b/solution/site/src/layouts/editorial/SectionHead.tsx
deleted file mode 100644
index 09b7716..0000000
--- a/solution/site/src/layouts/editorial/SectionHead.tsx
+++ /dev/null
@@ -1,191 +0,0 @@
-import type {ReactNode} from 'react';
-import {useSite} from '@site/lib/site-context';
-import {enabledSections, unitSpec} from '@site/lib/derive';
-
-/**
- * 편집(잡지)형 — 섹션 제목 · 차례.
- *
- * ★ 이 파일은 `@/lib/ui` 에서 아무것도 가져오지 않는다. `lib/ui/Section` 이 이 파일을
- * 불러다 쓰기 때문에, 반대 방향이 생기면 순환 참조로 빌드가 조용히 깨진다.
- * ★ 차례(useSectionIndex)와 결 무늬(GRAIN)도 여기 둔다. Shell·Rooms 가 둘 다 쓰는데
- * 의존 없는 이 파일이 잎사귀라 어느 쪽에서 가져가도 고리가 생기지 않는다.
- */
-
-/**
- * 종이 결.
- *
- * ★ 화면 전체를 덮는 오버레이로 두지 않는다 — 사진까지 누렇게 뜬다(레트로 안에서 밟은 함정).
- * `background-image` 는 그 요소의 배경색 위·내용 아래에 깔리므로, 종이 면에만 앉고
- * 그 위에 놓인 는 손대지 않는다.
- * ★ 색을 박지 않고 currentColor 를 3% 섞는다. 밝은 면에서는 옅은 회색, 어두운 면에서는
- * 옅은 밝은 선이 되어 어느 팔레트에서도 결이 남는다.
- */
-export const GRAIN =
- 'repeating-linear-gradient(90deg, color-mix(in oklab, currentColor 3%, transparent) 0 1px, transparent 1px 6px),' +
- 'repeating-linear-gradient(0deg, color-mix(in oklab, currentColor 2%, transparent) 0 1px, transparent 1px 6px)';
-
-export interface IndexEntry {
- /** 두 자리 번호. 차례와 섹션 제목이 같은 번호를 써야 목차가 목차 구실을 한다. */
- no: string;
- label: string;
- /** 실제 DOM 의 앵커 id. theme 의 섹션 id 와 다르다(rooms → units). */
- anchor: string;
-}
-
-/**
- * theme 섹션 id → 화면 앵커 id.
- *
- * ★ 두 이름이 다르다. 사장님 에디터의 섹션 id 는 'rooms'·'intro'·'photos' 인데,
- * 발행본이 실제로 그리는 는 'units'·'about'·'gallery' 다.
- * 여기 없는 id 는 렌더러에 컴포넌트가 없다는 뜻이라(HomePage 의 SECTION_COMPONENTS)
- * 차례에서도 조용히 빠진다 — 눌러도 아무 데도 안 가는 목차 줄이 최악이다.
- */
-const ANCHOR: Record = {
- intro: 'about',
- info: 'info',
- rooms: 'units',
- menu: 'units',
- programs: 'units',
- pricing: 'pricing',
- booking: 'booking',
- space: 'space',
- inquiry: 'inquiry',
- exhibition: 'exhibition',
- photos: 'gallery',
- local: 'guide',
- weather: 'weather',
- map: 'location',
- faq: 'faq',
- // 붙여넣기 아이템 — 섹션 id 가 곧 앵커다.
- songs: 'songs',
- daily: 'daily',
- chronicle: 'chronicle',
- reading: 'reading',
- people: 'people',
- quiz: 'quiz',
- postcard: 'postcard',
- video: 'video',
- itinerary: 'itinerary',
-};
-
-/** 사장님이 이름을 안 붙였을 때 차례에 적을 짧은 말. 제목이 아니라 목차 라벨이다. */
-const FALLBACK_LABEL: Record = {
- about: '소개',
- info: '이용 정보',
- pricing: '요금',
- booking: '예약 안내',
- space: '공간',
- inquiry: '문의',
- exhibition: '관람 안내',
- gallery: '사진',
- guide: '주변',
- weather: '날씨',
- location: '오시는 길',
- faq: '자주 묻는 질문',
- songs: '노래',
- daily: '일력',
- chronicle: '연표',
- reading: '읽기',
- people: '인물',
- quiz: '퀴즈',
- postcard: '엽서',
- video: '영상',
- itinerary: '일정',
-};
-
-function pad(n: number): string {
- return String(n).padStart(2, '0');
-}
-
-/**
- * 차례 — 켜져 있는 섹션을 순서대로.
- *
- * ★ 번호를 여기 한 곳에서만 매긴다. 왼쪽 차례와 섹션 제목의 번호가 어긋나면
- * 목차가 오히려 길을 잃게 만든다.
- * ★ 차례 라벨은 `theme.sections[].name` 을 그대로 쓴다. derive 의 sectionName() 이
- *
용으로는 이 값을 말리지만(짧은 목록 라벨이라서), 차례는 바로 그 목록이다.
- */
-export function useSectionIndex(): IndexEntry[] {
- const payload = useSite();
- const spec = unitSpec(payload);
- const seen = new Set();
- const entries: IndexEntry[] = [];
-
- for (const section of enabledSections(payload)) {
- const anchor = ANCHOR[section.id];
- if (!anchor || seen.has(anchor)) continue;
- // 객실이 없으면 그 판은 아예 안 그려진다(Rooms 가 null). 목차에만 남기지 않는다.
- if (anchor === 'units' && payload.units.length === 0) continue;
- seen.add(anchor);
- const name = section.name?.trim();
- entries.push({
- no: pad(entries.length + 1),
- label: name || FALLBACK_LABEL[anchor] || spec.label,
- anchor,
- });
- }
-
- // 오시는 길은 섹션 설정과 무관하게 항상 나간다(HomePage). 차례도 그걸 따라간다.
- if (!seen.has('location')) {
- entries.push({no: pad(entries.length + 1), label: '오시는 길', anchor: 'location'});
- }
-
- return entries;
-}
-
-/**
- * 섹션 제목 — 큰 두 자리 번호를 옅게 깔고 제목을 그 위에 겹친다.
- *
- * ★ 제목 위 굵은 실선(2px)이 판을 가른다. 색 띠 대신 선으로 가르는 건 인쇄물의 규칙이고,
- * 어떤 팔레트에서도 같은 세기로 보인다(currentColor).
- * ★ 번호는 aria-hidden 이다. 낭독기가 "영일 객실" 로 읽으면 제목이 아니라 잡음이 된다.
- */
-export function SectionHead({
- id,
- title,
- lead,
- aside,
-}: {
- id: string;
- title: string;
- lead?: string;
- aside?: ReactNode;
-}) {
- const no = useSectionIndex().find((entry) => entry.anchor === id)?.no;
-
- return (
-
-
-
-
-
- {no && (
-
- {no}
-
- )}
-
-
- {title}
-
-
- {lead && (
-
- {lead}
-
- )}
-
-
- {aside &&
{aside}
}
-
-
- );
-}
diff --git a/solution/site/src/layouts/editorial/Shell.tsx b/solution/site/src/layouts/editorial/Shell.tsx
deleted file mode 100644
index ab3ce9b..0000000
--- a/solution/site/src/layouts/editorial/Shell.tsx
+++ /dev/null
@@ -1,234 +0,0 @@
-import {useEffect, useState, type ReactNode} from 'react';
-import {useSite} from '@site/lib/site-context';
-import {bookingActionLabel, bookingLinks, channelLabel, primaryChannelLink} from '@site/lib/derive';
-import {formatKoreanDate, isoDate} from '@site/lib/format';
-// 배럴(@/sections)이 아니라 파일을 직접 가리킨다 — 배럴을 타면 섹션 전부가 딸려 온다.
-import {MobileTabBar} from '@site/sections/MobileTabBar';
-import {GRAIN, useSectionIndex, type IndexEntry} from './SectionHead';
-
-/**
- * 편집(잡지)형 껍데기.
- *
- * ★ 왼쪽 세로 차례가 이 안의 얼굴이다. 잡지의 등(spine)처럼 화면 왼쪽에 붙어 따라오고,
- * 스크롤에 따라 지금 읽는 항목만 진해진다. **데스크톱에서만** 세운다 — 좁은 화면에서
- * 목차는 자리만 먹고, 그 일은 이미 상단 제호 줄과 하단 고정 바가 한다.
- * ★ 차례를 fixed 로 띄우지 않고 flex 한 칸으로 세웠다. fixed 로 얹으면 어두운 판이
- * 밑으로 지나갈 때 목차 글자가 통째로 사라진다 — 칸으로 두면 항상 제 종이 위에 있다.
- * ★ 제호 줄에 전화·예약을 둔다. 국내 숙박 사이트에서 상단이 답해야 하는 건 그 둘뿐이다.
- */
-export function Shell({children}: {children: ReactNode}) {
- const payload = useSite();
- const {place, site} = payload;
- const index = useSectionIndex();
- const active = useActiveSection(index);
- const booking = bookingLinks(payload)[0];
- // ★ 확정 채널을 전부 늘어놓지 않는다 — 예약 우선으로 딱 하나만 낸다(seo/jsonld.ts 주석 참고).
- const link = primaryChannelLink(payload);
- const address = place.roadAddress ?? place.address;
-
- return (
-
- {/* 제호(masthead) — 얇은 한 줄, 아래는 굵은 실선. 잡지의 표제부다. */}
-
-
` 이지만 크게 쓰지 않는다. 크기는 왼쪽 워드마크가 이미 맡았고,
- * 여기서 큰 활자를 한 번 더 쓰면 화면에 큰 글자가 둘이 된다.
- * ★ 세로쓰기 캡션은 좁은 화면에서 감춘다 — 390px 에서는 사진 밖 여백이 없어
- * 그대로 두면 가로 스크롤이 생긴다.
- * ★ 사진 틀의 **높이를 고정한다**. 자연 비율로 두면 소스가 세로 사진일 때 첫 화면을
- * 사진 하나가 통째로 먹는다 — 실측(stay3, 1000×1333): 544×725px.
- * 사장님이 어떤 사진을 올리든 첫 화면 높이가 같아야 한다.
- */
-export function Hero() {
- const payload = useSite();
- const {place, narrative} = payload;
- const image = primaryImage(payload);
- const price = lowestPrice(payload);
- const links = bookingLinks(payload);
- /* 첫 화면 우선순위 key(체크인·영업시간…)가 하나도 없는 업종이 있다 —
- 그때 띠를 비우지 않고 확인된 이용 정보 앞 넷으로 채운다. */
- const priority = heroFacts(payload);
- const facts = priority.length > 0 ? priority : essentialRows(payload).slice(0, 4);
- const caption = image?.caption ?? place.name;
-
- return (
- /* data-oasi: 껍데기의 판 지우개(Shell PLATE_CSS)를 건너뛴다 — 여기는 이미 투명하고
- 위 여백도 스스로 정한다(첫 섹션이라 다른 섹션보다 좁다). */
-
-
- {place.name}
-
-
- {narrative.heroHeadline && (
-
- {narrative.heroHeadline}
-
- )}
-
- {image && (
-
-
- {/* 캡션 높이를 액자에 묶고(max-h-full) 한 줄로 묶는다(nowrap).
- 22rem 로 박아 두면 액자보다 캡션이 길어져 사진 아래로 흘러내리고,
- 긴 캡션은 세로쓰기가 여러 단으로 갈려 액자 옆이 글자 벽이 된다. */}
-
- {caption}
-
-
- )}
-
- {(price || facts.length > 0) && (
-
- {price && (
-
-
최저가
-
{price}
-
- )}
- {facts.map((row) => (
-
-
{row.label}
-
{row.value}
-
- ))}
-
- )}
-
- {/* 확정된 예약 채널만. 버튼 상자를 만들지 않는다 — 이 안에는 면이 없다. */}
- {links.length > 0 && (
-
- )}
-
- );
-}
diff --git a/solution/site/src/layouts/oasi/Rooms.tsx b/solution/site/src/layouts/oasi/Rooms.tsx
deleted file mode 100644
index e809037..0000000
--- a/solution/site/src/layouts/oasi/Rooms.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-import {useSite} from '@site/lib/site-context';
-import {bookingActionLabel, bookingLinks, sectionName, unitSpec, unitViews} from '@site/lib/derive';
-import {SectionHead} from './SectionHead';
-
-/**
- * oasi 객실 안내 — 카드 격자가 아니라 **사진 한 장씩 내려가는 지면**이다.
- *
- * ★ `@/lib/ui/Section` 을 쓰지 않고 `` 을 직접 그린다.
- * 그쪽은 섹션마다 배경(`--tpl-surface`)과 아래 실선을 칠하는데, 이 안은 배경이
- * 한 톤이고 면이 갈리지 않는 게 정체성이다 — 그걸 쓰면 색 띠가 생긴다.
- * ★ 값은 상자에 넣지 않는다. 요금·스펙은 가는 실선 하나로만 구분한다.
- * ★ 사진은 한 장도 빼지 않는다. 대표 한 장은 세로 캡션과 함께 크게, 나머지는
- * 아래 작은 격자로 — 감추면 HTML 에서 사라져 검색·AI 가 못 본다.
- * ★ 사진 틀은 높이를 고정한다. 자연 비율로 두면 객실마다 사진 높이가 제각각이라
- * 지면이 아니라 무너진 격자가 된다 — 실측(stay3): 대표 사진 544×725 ↔ 544×306,
- * 아래 2열 격자 268×178 · 268×335 · 268×268.
- */
-export function Rooms() {
- const payload = useSite();
- const spec = unitSpec(payload);
- const units = unitViews(payload);
- const booking = bookingLinks(payload)[0];
-
- if (units.length === 0) return null;
-
- return (
- /* data-oasi + pt-[var(--oasi-gap)]: 껍데기의 판 지우개를 건너뛰되(이미 투명하다)
- 섹션 사이 여백은 공용 섹션과 같은 값을 쓴다 — 여기만 다르면 리듬이 끊긴다. */
-
-
-
-
- {units.map((unit) => (
-
- {unit.images[0] && (
-
-
- {/* 세로 캡션은 한 줄로 묶는다(nowrap). 객실 이름이 길면
- ("A동 (일본식 가정집 느낌으로 …")) 세로쓰기가 오른쪽으로 여러 단을
- 만들어 액자 옆이 글자 벽이 된다. 잘려도 바로 아래
에 전문이 있다. */}
-
- {unit.name}
-
-
- )}
-
-
- {unit.name}
-
-
- {/* 스펙은 칩 상자 대신 가운뎃점으로 잇는다 — 이 안에는 테두리 상자가 없다. */}
- {unit.chips.length > 0 && (
-
- )}
-
- {/* 요금은 한 값이 아니라 비교하는 값이다 — 주중만 적으면 손님이 주말을 모른 채 떠난다. */}
- {/* 요금은 객실 정보에서 내지 않는다 — 사유는 sections/UnitsSection.tsx 의 같은 자리 주석. */}
-
- {unit.rows.length > 0 && (
-
-
- );
-}
diff --git a/solution/site/src/layouts/oasi/SectionHead.tsx b/solution/site/src/layouts/oasi/SectionHead.tsx
deleted file mode 100644
index c6fdd15..0000000
--- a/solution/site/src/layouts/oasi/SectionHead.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import type {ReactNode} from 'react';
-
-/**
- * oasi 섹션 제목 — **큰 제목이 없다.**
- *
- * 대괄호로 감싼 작은 라벨 한 줄이 제목이고, 설명은 그 아래 더 작은 글씨로 온다.
- * ★ `.h2` 를 쓰지 않는다. 그 클래스는 템플릿 제목 굵기·자간을 그대로 받아서
- * 이 안에서만 제목이 굵고 크게 튄다 — 이 디자인의 정체성이 그 반대다.
- * ★ `@/lib/ui` 에서 아무것도 들여오지 않는다. `lib/ui/Section` 이 이 파일을 부르므로
- * 반대 방향 import 가 생기면 순환 참조다.
- * ★ `
` 은 반드시 그린다 — 바깥 section 의 aria-labelledby 가 이걸 가리킨다.
- */
-export function SectionHead({
- id,
- title,
- lead,
- aside,
-}: {
- id: string;
- title: string;
- lead?: string;
- aside?: ReactNode;
-}) {
- return (
-
-
-
- [{title}]
-
- {lead && (
-
{lead}
- )}
-
- {aside &&
{aside}
}
-
- );
-}
diff --git a/solution/site/src/layouts/oasi/Shell.tsx b/solution/site/src/layouts/oasi/Shell.tsx
deleted file mode 100644
index c88e256..0000000
--- a/solution/site/src/layouts/oasi/Shell.tsx
+++ /dev/null
@@ -1,400 +0,0 @@
-import {HeroCatchphrase} from '@site/sections/HeroCatchphrase';
-import type {CSSProperties, ReactNode} from 'react';
-import {isoDate} from '@site/lib/format';
-import {useSite} from '@site/lib/site-context';
-import {
- bookingActionLabel,
- bookingLabel,
- bookingLinks,
- contactLinks,
- heroFacts,
- isSectionEnabled,
- unitSpec,
-} from '@site/lib/derive';
-// ★ 배럴(`@/sections`)을 거치면 껍데기 하나가 사이트의 모든 섹션을 끌고 들어온다.
-// 껍데기는 하단 고정 바 하나만 필요하다 — 파일을 직접 가리킨다.
-import {MobileTabBar} from '@site/sections/MobileTabBar';
-
-/**
- * 본문 섹션의 색 띠를 걷어낸다 (2026-09-04, 사장님 지적).
- *
- * ★ 실측: 히어로는 투명(바탕 #e5ddd2)인데 소개가 rgb(219,211,200), 예약 전 확인이
- * rgb(222,213,200), 객실이 다시 투명, 요금·이용정보가 rgb(222,213,200) 이었다.
- * 밝기가 오르락내리락하는 띠가 여덟 번 반복된다. 이 안에는 **면이 없다** —
- * Hero 주석의 "버튼 상자를 만들지 않는다"와 같은 규칙이 섹션에도 걸려야 한다.
- * ★ !important 인 이유: 공용 Section 이 배경색과 세로 여백을 **인라인 style** 로 박는다
- * (lib/ui/Section.tsx:82). 인라인은 선택자 특정도로 못 이기므로 이 자리에서만 강제한다.
- * 제 폴더가 직접 그린 판(Hero·Rooms)은 `data-oasi` 로 표시해 건너뛴다 — 이미 투명이고
- * 위 여백을 스스로 정한다.
- * ★ 글자색도 같이 강제한다. tone='dark' 섹션이 인라인으로 밝은 글자를 넣는데,
- * 면이 사라지면 그 글자가 갱지 위에 하얗게 떠서 안 읽힌다.
- * ★ 아래 실선(border-b)도 지운다. 색 띠를 지우고 선만 남기면 이번엔 줄만 여덟 개 그어진다.
- * ★ `.shell` 의 좌우 여백을 0 으로 돌린다. 껍데기가 이미 본문 폭을 잡아 뒀는데
- * 섹션 안에서 한 번 더 들어가서, 실측 1440px 에서 공용 섹션 제목만 40px 안쪽에
- * 있었다(히어로·객실 569px ↔ 소개·요금 609px).
- *
- * ★ 뒤의 두 규칙은 **본문 칸이 716px 로 고정**이라 생기는 어긋남을 되돌린다.
- * 공용 섹션은 1200px 지면을 전제로 `lg:`·`xl:` 에서 칸을 나누는데, 그 중단점은
- * 화면 폭이지 이 칸의 폭이 아니다. 이 껍데기는 화면이 아무리 넓어도 칸이 안 넓어진다.
- * - #about: 사진 7 · 글 5 로 갈리면 글 칸이 275px 다 — 한 줄에 열네 자.
- * 게다가 세로 가운데 정렬이라 짧은 사진이 허공에 뜨고 위아래로 200px 씩 빈다.
- * → 위아래로 쌓아 사진도 글도 칸을 다 쓰게 한다(글은 .measure 가 줄 길이를 잡는다).
- * - #summary: 값 표가 xl 에서 두 칸으로 갈려 한 칸이 179px 이 됐다.
- * 환불 규정 한 문장이 여덟 줄로 감긴다 → 한 칸으로 되돌린다.
- *
- * ─────────────────────────────────────────────────────────────────────────────
- * 남아 있던 마지막 면 — 공용 `.panel` (2026-09-04, 사장님 "다 고쳐")
- *
- * ★ 섹션 띠를 지우고 나니 `.panel`(index.css:197, 글자색 5% 틴트 + 실선)만 남아
- * 이 안에서 유일하게 떠 있는 상자가 됐다. 실측 64개 — FAQ 32 · 주변 24 · 축제 4 ·
- * 이용정보 2 · 오시는 길 1 · 날씨 1.
- * ★ 그냥 지우면 FAQ 서른둘이 구분 없는 글 덩어리가 된다. 그래서 **면을 걷고 구분을
- * 다시 세운다** — 인쇄물이 상자 없이 목록을 가르는 두 가지, 가는 괘선과 들여쓰기다.
- * ★ !important 를 쓰지 않는다. 이 '].join('\n');
}
-/**
- * 템플릿이 요구하는 웹폰트만 골라 한 번에 받아 온다.
- *
- * ★ 서체 스택 문자열에서 이름을 훑어 아는 것만 붙인다. 전부 항상 실으면 쓰지도 않는 서체가
- * 모든 발행 사이트의 첫 렌더를 늦춘다 — 서체 하나가 그럴 이유가 없다.
- */
+/** 템플릿이 요구하는 웹폰트만 골라 한 번에 받아 온다. */
const WEB_FONTS: [RegExp, string][] = [
[/Noto Sans KR/i, 'family=Noto+Sans+KR:wght@300..900'],
- [/Noto Serif KR/i, 'family=Noto+Serif+KR:wght@300..700'],
+ [/Noto Serif KR/i, 'family=Noto+Serif+KR:wght@300..900'],
[/Gugi/i, 'family=Gugi'],
[/Gowun Batang/i, 'family=Gowun+Batang:wght@400;700'],
[/Nanum Pen Script/i, 'family=Nanum+Pen+Script'],
+ [/Nanum Myeongjo/i, 'family=Nanum+Myeongjo:wght@400;700;800'],
+ [/Gowun Dodum/i, 'family=Gowun+Dodum'],
+ [/Black Han Sans/i, 'family=Black+Han+Sans'],
+ [/IBM Plex Mono/i, 'family=IBM+Plex+Mono:wght@400;500'],
+ [/Cormorant Garamond/i, 'family=Cormorant+Garamond:ital,wght@0,300;0,400;1,300;1,400'],
+ [/Marcellus/i, 'family=Marcellus'],
+ [/Aboreto/i, 'family=Aboreto'],
+ [/Cinzel/i, 'family=Cinzel:wght@400;500'],
+ [/Questrial/i, 'family=Questrial'],
+ [/Song Myung/i, 'family=Song+Myung'],
+ [/Hahmlet/i, 'family=Hahmlet:wght@300..900'],
+ [/Jua/i, 'family=Jua'],
+ [/Gothic A1/i, 'family=Gothic+A1:wght@200;300;400;500;700'],
+ [/Diphylleia/i, 'family=Diphylleia'],
+ [/Nanum Gothic/i, 'family=Nanum+Gothic:wght@400;700'],
+ [/IBM Plex Sans KR/i, 'family=IBM+Plex+Sans+KR:wght@300;400;600'],
];
-/** 템플릿이 요구하는 웹폰트 주소. 발행본 와 빌더 미리보기가 같은 것을 쓴다. */
+/** 템플릿이 요구하는 웹폰트 주소. */
export function fontHref(payload: SitePayload): string {
const stacks = [
'Noto Sans KR',
@@ -145,29 +131,10 @@ export function renderHead({payload, meta, scriptSrc, cssHrefs = []}: HeadOption
' ',
` ${escapeHtml(meta.title)}`,
tag('meta', {name: 'description', content: meta.description}),
- /*
- * SiteOntology 키워드. 구글은 이 태그를 순위에 쓰지 않는다 — 비용이 없어 싣는 자리이고,
- * 순위에 닿는 자리는 위의 title 이다(meta.ts homeTitle).
- * ★ 조건부로 넣는다. tag() 는 빈 **속성**만 빼고 태그는 만든다 — 그대로 두면 키워드가 없는
- * 모든 사이트에 `` 빈 태그가 박힌다(meta.test.ts 가 잡았다).
- */
+ /* SiteOntology 키워드. */
...(meta.keywords ? [tag('meta', {name: 'keywords', content: meta.keywords})] : []),
tag('link', {rel: 'canonical', href: meta.canonical}),
- /*
- * ★ **발행된 사이트만** 색인된다.
- *
- * 예전엔 `index, follow` 를 박아 두고 "색인을 막을 이유가 없다"고 적어 뒀는데, 그건
- * 굽는 것이 곧 발행이던 시절의 말이다. 지금은 사장님이 빌더에서 미리보기를 누르면
- * draft 상태로도 구워진다 — 그 결과가 그대로 검색에 나갔다.
- *
- * 실측(2026-09-15): 디스크의 발행본 33곳 중 **15곳이 draft 인데 `index, follow`** 였고
- * 사이트맵에도 올라가 있었다. 사장님이 발행 버튼을 누른 적 없는 사이트가,
- * 짓다 만 상태로 구글에 실려 있었다는 뜻이다.
- *
- * ★ 여기만 고치면 사이트맵·`/s` 목록·llms.txt 에서도 함께 빠진다 —
- * 그쪽은 구운 HTML 의 robots 를 읽어 거른다(seo/directory.ts readBakedNoindex).
- * 두 자리에 규칙을 두지 않기 위해 판정을 이 한 곳에 둔다.
- */
+ /* **발행된 사이트만** 색인된다. */
tag('meta', {
name: 'robots',
content:
@@ -176,24 +143,12 @@ export function renderHead({payload, meta, scriptSrc, cssHrefs = []}: HeadOption
: // follow 는 남긴다 — 색인은 막되 링크는 타게 둔다(자산·하위 경로 발견용).
'noindex, follow',
}),
- /*
- * ★ 파비콘. 발행본에는 아예 없어서 브라우저 탭에 기본 아이콘이 떴다(실측 2026-09-15).
- * 파일은 오리진 루트의 공용 자산이라 사이트마다 복사하지 않는다 — `basePath` 가 아니라
- * 루트 절대경로로 가리킨다(robots·sitemap 과 같은 자리다).
- */
+ /* 파비콘. */
tag('link', {rel: 'icon', type: 'image/png', sizes: '32x32', href: '/brand/favicon-w4a.png'}),
tag('link', {rel: 'icon', type: 'image/svg+xml', href: '/brand/favicon-w4a.svg'}),
tag('link', {rel: 'apple-touch-icon', href: '/apple-touch-icon.png'}),
tag('meta', {'http-equiv': 'content-language', content: 'ko-KR'}),
- /*
- * ★ 노치·상태바를 템플릿 색으로 채운다 (2026-09-09, 사장님: "모바일에서 노치가 투명으로 되던데")
- * `viewport-fit=cover` 로 화면 끝까지 쓰는데 `theme-color` 가 없어서, 아이폰의 상태바
- * 자리와 안드로이드 크롬의 상단 띠가 **브라우저 기본색(흰색·검은색)** 으로 남았다.
- * 갱지 바탕 위에 흰 띠가 얹히면 페이지가 화면에 안 붙은 것처럼 보인다.
- * ★ 색은 헤더가 쓰는 것과 같은 `surface` 다 — 화면 맨 위에 실제로 깔리는 면이 그것이다.
- * `bg` 를 쓰면 헤더와 한 칸 어긋난다.
- * ★ 사이트마다 색이 다르므로 payload 에서 유도한다(themeStyle 과 같은 식).
- */
+ /* 색은 헤더가 쓰는 것과 같은 `surface` 다 — 화면 맨 위에 실제로 깔리는 면이 그것이다. */
tag('meta', {name: 'theme-color', content: deriveSurfaces(payload.theme.colors).surface}),
// 신선도 — AI 검색이 오래된 페이지를 뒤로 미룬다.
diff --git a/solution/site/src/seo/jsonld.ts b/solution/site/src/seo/jsonld.ts
index 54b454b..51af4e0 100644
--- a/solution/site/src/seo/jsonld.ts
+++ b/solution/site/src/seo/jsonld.ts
@@ -14,20 +14,9 @@ import {
type UnitInfo,
} from '@o2o/shared';
-/**
- * 구조화 데이터(JSON-LD).
- *
- * 이 프로젝트의 목표 문장 그대로 — "AI 검색이 이 가게를 공식 홈페이지 기준으로 설명하게 만드는 것".
- * 그 설명의 재료가 여기서 나간다.
- *
- * ★ 두 가지를 절대 어기지 않는다.
- * 1) 화면에 없는 값을 JSON-LD 에 넣지 않는다. 어긋나면 구조화 데이터 스팸으로 취급되고,
- * 백엔드 발행 게이트도 JSONLD_MISMATCH 로 막는다.
- * 2) 확인되지 않은 fact 는 넣지 않는다. 전부 selectPublishable() 을 통과한 값만 쓴다.
- * "값이 없다"와 "확인 전이라 가렸다"를 구분하지 않고, 둘 다 그냥 내보내지 않는다.
- */
+/** 구조화 데이터(JSON-LD). */
-/** 업종 → Schema.org 최상위 타입. 업종이 늘면 여기 한 줄만 는다. */
+/** 업종 → Schema.org 최상위 타입. */
export const SCHEMA_TYPE: Record = {
[PlaceCategory.LODGING]: 'LodgingBusiness',
[PlaceCategory.CAFE]: 'CafeOrCoffeeShop',
@@ -45,7 +34,7 @@ export const UNIT_SPEC: Record;
-/** null·undefined·빈 문자열·빈 배열 키를 통째로 뺀다. 빈 값이 실린 JSON-LD 는 감점이다. */
+/** null·undefined·빈 문자열·빈 배열 키를 통째로 뺀다. */
function compact(input: T): T {
const out: Json = {};
for (const [key, value] of Object.entries(input)) {
@@ -60,18 +49,14 @@ function siteUrl(payload: SitePayload, ...parts: string[]): string {
return joinUrl(payload.site.origin, payload.site.basePath, ...parts);
}
-/**
- * amenityFeature — bool 타입 fact 를 시설 목록으로 옮긴다.
- * 확인된 것만 들어가고, false 도 그대로 실린다("반려동물 불가"는 중요한 답이다).
- */
+/** amenityFeature — bool 타입 fact 를 시설 목록으로 옮긴다. */
function amenityFeatures(facts: FactEntry[]): Json[] {
return selectPublishable(facts)
.filter((fact) => fact.type === 'bool')
.map((fact) =>
compact({
'@type': 'LocationFeatureSpecification',
- // ★ 화면과 **같은** 이름표를 쓴다. 화면은 "주차 | 가능" 인데 여기만 "주차 가능" 이면
- // 발행 게이트가 "구조화 데이터가 화면 값과 다르다"로 막는다 — 실제로 막혔다.
+ // 발행 게이트가 "구조화 데이터가 화면 값과 다르다"로 막는다 — 실제로 막혔다.
name: displayFactLabel(fact),
value: factBool(facts, fact.key) ?? undefined,
}),
@@ -98,14 +83,7 @@ function geoCoordinates(payload: SitePayload): Json | undefined {
return {'@type': 'GeoCoordinates', latitude, longitude};
}
-/**
- * 대표 이미지 우선, 대체 텍스트가 있는 것만. alt 없는 이미지는 애초에 렌더도 안 한다.
- *
- * ★ 우리 자산으로 미러한 사진은 payload 에 **루트 절대경로**로 들어 있다
- * (`/assets/mirror/…`, scripts/mirror-payload-images.mjs). 그대로 내보내면
- * 구조화 데이터를 읽는 쪽이 어느 호스트인지 모른다 — Schema.org 의 `image` 는
- * 가져갈 수 있는 주소여야 하므로 오리진을 붙인다.
- */
+/** 대표 이미지 우선, 대체 텍스트가 있는 것만. */
function imageUrls(payload: SitePayload, limit = 6): string[] {
return payload.media
.filter((m) => m.alt?.trim())
@@ -114,35 +92,18 @@ function imageUrls(payload: SitePayload, limit = 6): string[] {
.map((m) => (m.url.startsWith('/') ? joinUrl(payload.site.origin, m.url) : m.url));
}
-/**
- * 손님이 눌러서 열 수 있는 확정 채널 — **화면과 `sameAs` 가 같은 목록을 쓴다.**
- *
- * ★ `http(s)` 가 아닌 것을 뺀다 (실측 2026-09-10, 그래비티 조선)
- * 수집이 TourAPI 콘텐츠를 `tour://32/2819450` 이라는 **내부 식별자**로 채널에 적어 둔다
- * (`collect_service.py`). 이건 주소가 아니라 "이 업장은 관광공사 콘텐츠 2819450 이다"라는
- * 메모라, 브라우저가 열 수 없고 schema.org `sameAs`(공개 프로필 주소)에도 해당하지 않는다.
- * 그런데 확정 링크를 통째로 실어서 JSON-LD 에는 나가고 화면에는 못 그려졌다 —
- * **화면과 구조화 데이터가 다르다**로 발행 게이트가 막혔다(절대규칙 3).
- * 게이트를 느슨하게 푸는 게 아니라 **애초에 안 내보내는 것**이 맞다. 게이트는 옳았다.
- * ★ 이 판단을 한 곳에 둔다. 화면(SiteFooter)과 sameAs(seo/jsonld)가 각자 거르면
- * 한쪽만 고쳤을 때 같은 종류로 또 막힌다.
- */
+/** 손님이 눌러서 열 수 있는 확정 채널 — **화면과 `sameAs` 가 같은 목록을 쓴다. */
export function publicLinks(payload: SitePayload): ChannelLink[] {
return payload.links.filter((link) => link.confirmed && /^https?:\/\//i.test(link.url ?? ''));
}
-/**
- * 확정된 채널만 sameAs 로 나간다 — 확정 전 URL 은 동명 업소일 수 있다.
- * ★ 목록은 화면(SiteFooter)과 **같은 함수**에서 온다(`primaryChannelLink`) — 갈리면 게이트가
- * 막는다. 화면이 "공식 채널" 하나만 보여주는데 sameAs 가 여럿을 주장하면 그것도 어긋남이다
- * (2026-09-17).
- */
+/** 확정된 채널만 sameAs 로 나간다 — 확정 전 URL 은 동명 업소일 수 있다. */
function sameAs(payload: SitePayload): string[] {
const link = primaryChannelLink(payload);
return link ? [link.url] : [];
}
-/** 숙박의 체크인·체크아웃은 Schema.org 에 전용 속성이 있다. 있으면 반드시 채운다. */
+/** 숙박의 체크인·체크아웃은 Schema.org 에 전용 속성이 있다. */
function lodgingExtras(payload: SitePayload): Json {
const {facts} = payload;
return {
@@ -164,7 +125,7 @@ function categoryExtras(payload: SitePayload): Json {
};
}
-/** 하위 단위(객실·메뉴·프로그램). 확인된 fact 만 스펙으로 붙는다. */
+/** 하위 단위(객실·메뉴·프로그램). */
function unitNodes(payload: SitePayload): Json[] {
const spec = UNIT_SPEC[payload.place.category];
const mediaById = new Map(payload.media.map((m) => [m.mediaId, m]));
@@ -205,16 +166,14 @@ function floorSize(facts: FactEntry[]): Json | undefined {
return {'@type': 'QuantitativeValue', value: Number(size) || size, unitCode: 'MTK'};
}
-/** 사업장 본체. 모든 페이지에 같은 @id 로 실린다 — AI 가 페이지들을 한 업소로 묶는 열쇠다. */
+/** 사업장 본체. */
export function businessJsonLd(payload: SitePayload): Json {
const {place, narrative} = payload;
const spec = UNIT_SPEC[place.category];
return compact({
'@context': 'https://schema.org',
- // ★ 특화 타입 하나만 쓰면 `LodgingBusiness` 를 LocalBusiness 로 못 펴는 파서가 있다.
- // 상위 타입을 같이 적어야 "이 페이지에 사업장 엔티티가 있다"가 문자열 대조로도 잡힌다.
- // (schema.org 상 중복이지 모순이 아니다 — LodgingBusiness ⊂ LocalBusiness ⊂ Organization)
+ // 상위 타입을 같이 적어야 "이 페이지에 사업장 엔티티가 있다"가 문자열 대조로도 잡힌다.
'@type': [SCHEMA_TYPE[place.category] ?? 'LocalBusiness', 'LocalBusiness', 'Organization'],
'@id': `${siteUrl(payload)}#business`,
name: place.name,
@@ -236,17 +195,7 @@ export function businessJsonLd(payload: SitePayload): Json {
});
}
-/**
- * 객실 기준 요금 — **화면과 JSON-LD 가 같은 숫자를 쓰게 하는 단일 출처.**
- *
- * ★ 왜 여기 있나
- * 화면의 요금 표기(`derive.unitPriceText`)와 JSON-LD 의 `makesOffer.price` 가 각자
- * 계산하면 둘이 갈라질 수 있고, 갈라지는 순간 절대규칙 3(화면 = 구조화 데이터) 위반이라
- * 발행 게이트가 사이트를 막는다. 그래서 숫자를 고르는 함수는 하나뿐이고, 양쪽이 이걸 쓴다.
- * ★ 주중 요금을 기준으로 삼는다 — 손님이 "얼마부터"로 읽는 값이고, 주말/성수기는 그보다 비싸다.
- * `label` 을 같이 돌려주는 이유: 화면이 "주중 280,000원" 이라고 쓰면 구조화 데이터의
- * `unitText` 도 같은 말이어야 한다. 어느 요금인지 안 밝힌 가격은 그 자체로 오해다.
- */
+/** 객실 기준 요금 — **화면과 JSON-LD 가 같은 숫자를 쓰게 하는 단일 출처. */
export function unitBaseRate(unit: UnitInfo): {price: number; label: string} | undefined {
for (const [key, label] of [
['weekday_price', '주중 1박'],
@@ -260,18 +209,7 @@ export function unitBaseRate(unit: UnitInfo): {price: number; label: string} | u
return undefined;
}
-/**
- * makesOffer — 객실별 1박 요금. **숙박만** 낸다.
- *
- * ★ 왜 `containsPlace` 안이 아니라 여기인가
- * `HotelRoom` 은 Accommodation 이라 `offers` 가 정식 속성이 아니다. 요금을 파는 주체는
- * 사업장이므로 Organization 계열의 `makesOffer` 가 맞는 자리다. 대조기(verify.ts)도
- * 이 속성을 이름·가격 쌍으로 따로 검사한다.
- * ★ `availability` 는 넣지 않는다. 우리는 빈 방 재고를 모른다 — 모르는 것을 InStock 으로
- * 주장하면 그게 거짓이고, 예약 채널이 마감인데 AI 가 "예약 가능" 이라고 답하게 된다.
- * ★ `url` 은 확정된 예약 채널뿐이다. 없으면 넣지 않는다(자기 페이지로 돌려보내는 예약 URL 은
- * 예약 경로가 아니다).
- */
+/** makesOffer — 객실별 1박 요금. */
function stayOffers(payload: SitePayload): Json[] {
if (payload.place.category !== PlaceCategory.LODGING) return [];
const spec = UNIT_SPEC[payload.place.category];
@@ -300,20 +238,8 @@ function stayOffers(payload: SitePayload): Json[] {
.filter((offer): offer is Json => offer !== null);
}
-/**
- * 예약을 실제로 받는 채널 URL 하나. 화면의 예약 버튼과 같은 목록에서 고른다
- * (`derive.bookingLinks` — 야놀자·여기어때·네이버 플레이스, 확정된 것만).
- *
- * ★ 목록을 두 곳에 적지 않으려면 derive 를 부르는 쪽이 자연스럽지만, 의존 방향이
- * derive → jsonld 라 반대로 부를 수 없다. 채널 코드 목록은 이 파일에 두고
- * derive 가 이걸 쓴다.
- */
+/** 예약을 실제로 받는 채널 URL 하나. */
export const BOOKING_CHANNELS: readonly LinkChannel[] = [
- // ★ 순서가 곧 우선순위다. 예약 화면으로 **바로 가는** 채널이 앞이다.
- // 네이버 예약(m.booking.naver.com)은 눌렀을 때 예약 화면 그 자체가 뜨고,
- // 네이버 플레이스는 잘해야 가게 홈이라 예약을 한 번 더 눌러야 한다.
- // 실측(2026-09-08): 자동 발견이 물어온 플레이스 URL 이 검색 결과 주소였던 사장님은
- // "예약" 을 눌렀는데 검색 화면을 봤다. 예약하러 온 손님은 거기서 끝난다.
LinkChannel.NAVER_BOOKING,
LinkChannel.YANOLJA,
LinkChannel.GOODCHOICE,
@@ -325,17 +251,7 @@ function isSearchUrl(url: string): boolean {
return /\/p\/search\/|[?&]query=/.test(url);
}
-/**
- * 예약을 실제로 받는 확정 채널 전부, **BOOKING_CHANNELS 순서대로**.
- *
- * ★ 검색 결과 주소는 뺀다 — 자동 발견이 `map.naver.com/p/search/…` 를 물어오는 경우가 있고
- * (실측 2026-09-08), 가게를 특정하지 못하는 주소라 예약 창구가 아니다.
- * ★ 네이버 예약이 있으면 네이버 플레이스는 뺀다 — 둘 다 남으면 화면에 "네이버 예약"과
- * "네이버 플레이스에서 예약"이 나란히 떠서 같은 예약이 두 번 있는 것처럼 보인다
- * (2026-09-17). 네이버 플레이스는 직행 예약 채널이 없을 때만 쓰는 대체 창구다.
- * ★ 화면(BookingSection 등)과 이 함수를 같이 쓴다 — 갈리면 makesOffer/potentialAction 이
- * 가리키는 곳과 화면 버튼이 어긋난다.
- */
+/** 예약을 실제로 받는 확정 채널 전부, **BOOKING_CHANNELS 순서대로**. */
export function bookingChannelLinks(payload: SitePayload): ChannelLink[] {
const order = new Map(BOOKING_CHANNELS.map((channel, index) => [channel, index]));
const links = payload.links.filter(
@@ -347,18 +263,7 @@ export function bookingChannelLinks(payload: SitePayload): ChannelLink[] {
.sort((a, b) => (order.get(a.channel) ?? 99) - (order.get(b.channel) ?? 99));
}
-/**
- * 푸터·판권장의 "공식 채널"에 낼 링크 **딱 하나**. `sameAs` 도 이 함수 하나를 같이 쓴다 —
- * 갈리면 발행 게이트가 막는다(절대규칙 3, 위 `publicLinks` 주석 참고).
- *
- * ★ 왜 하나뿐인가 (2026-09-17)
- * 확정 채널을 전부 늘어놓으면 야놀자·인스타·네이버플레이스·네이버예약이 나란히 서서
- * 손님이 어디를 눌러야 할지 고르게 만든다. 예약은 이미 본문·상단 버튼이 답했다 —
- * 맨 아래는 "여기가 이 업장이 맞다"만 확인해 주면 된다.
- * ★ 우선순위: 공식 사이트 → 예약 채널(`bookingChannelLinks` 순서) → 그래도 없으면
- * 확정된 링크 중 첫 번째(2026-09-17 수정: 공식 사이트가 있으면 그게 최우선이다 —
- * 이 자리는 "업장 대표 주소"를 보증하는 자리지 예약 창구가 아니다).
- */
+/** 푸터 "공식 채널" 링크 하나. */
export function primaryChannelLink(payload: SitePayload): ChannelLink | undefined {
return (
payload.links.find((link) => link.confirmed && link.channel === LinkChannel.OFFICIAL_SITE) ??
@@ -367,24 +272,12 @@ export function primaryChannelLink(payload: SitePayload): ChannelLink | undefine
);
}
-/**
- * 예약 화면으로 보낼 URL 하나. **가장 앞선 채널**을 고른다(BOOKING_CHANNELS 순서).
- *
- * 화면의 예약 버튼과 `makesOffer.url`·`potentialAction` 이 같은 함수를 쓰므로,
- * 구조화 데이터가 가리키는 곳과 손님이 눌러서 가는 곳이 어긋날 수 없다.
- */
+/** 예약 화면으로 보낼 URL 하나. */
function bookingChannelUrl(payload: SitePayload): string | undefined {
return bookingChannelLinks(payload)[0]?.url;
}
-/**
- * potentialAction — "이 업소를 예약하는 방법" 을 기계가 읽는 형태로.
- *
- * AI 검색이 "여기 예약 어떻게 해요?" 에 답할 때 근거로 쓰는 자리다. 확정된 예약 채널이
- * 없으면 내보내지 않는다 — 예약을 받지 않는 곳에 예약 액션을 붙이면 그게 거짓이다.
- * ★ `actionPlatform` 은 쓰지 않는다. 값이 schema.org URL 이라 화면 대조에서 "화면에 없는
- * URL" 로 잡히고, 플랫폼 구분은 이 사이트에서 아무 의미도 없다.
- */
+/** potentialAction — "이 업소를 예약하는 방법" 을 기계가 읽는 형태로. */
function reserveAction(payload: SitePayload): Json | undefined {
if (payload.place.category !== PlaceCategory.LODGING) return undefined;
const url = bookingChannelUrl(payload);
@@ -396,7 +289,7 @@ function reserveAction(payload: SitePayload): Json | undefined {
};
}
-/** 최저~최고 요금. 단위 fact 의 숫자만 모은다 — 확인 안 된 요금은 애초에 안 들어온다. */
+/** 최저~최고 요금. */
function priceRange(payload: SitePayload): string | undefined {
const prices = sanitizeUnits(payload.units)
.flatMap((unit) =>
@@ -412,21 +305,14 @@ function priceRange(payload: SitePayload): string | undefined {
return min === max ? `${fmt(min)}원` : `${fmt(min)}원 ~ ${fmt(max)}원`;
}
-/** FAQ 가 화면에 그려지는가 — 섹션이 켜져 있을 때만이다(HomePage 가 enabled 로 고른다). */
+/** FAQ 가 화면에 그려지는가 — 섹션이 켜져 있을 때만이다(SectionList 가 enabled 로 고른다). */
function isFaqOnScreen(payload: SitePayload): boolean {
return payload.theme.sections.some((section) => section.id === 'faq' && section.enabled);
}
-/**
- * FAQPage — AEO 에서 가장 크게 먹히는 마크업.
- * "체크인 몇 시예요?" 같은 질문에 이 홈페이지가 답으로 잡히는 자리다.
- * 확인된 FAQ 가 하나도 없으면 아예 내보내지 않는다(빈 FAQPage 는 감점).
- * ★ 문의 안내(TEMPLATE)는 싣지 않는다 — 답이 없는 문답이다(shared selectAnsweredFaqs).
- */
+/** FAQPage — AEO 에서 가장 크게 먹히는 마크업. */
export function faqJsonLd(payload: SitePayload): Json | null {
- // ★ 화면에 FAQ 가 없으면 내보내지 않는다 (2026-09-14). 섹션이 꺼져 있으면 HomePage 가 FaqSection 을
- // 아예 그리지 않는데, 여기서만 실으면 **구조화 데이터가 화면 값과 다르다**로 발행이 통째로 막힌다.
- // 실측: FAQ 를 20개까지 채우자(COPY) 섹션을 꺼 둔 사이트에서 12건 불일치로 발행 실패.
+ // 아예 그리지 않는데, 여기서만 실으면 **구조화 데이터가 화면 값과 다르다**로 발행이 통째로 막힌다.
if (!isFaqOnScreen(payload)) return null;
const faqs = selectAnsweredFaqs(payload.faqs);
if (faqs.length === 0) return null;
@@ -455,7 +341,7 @@ export function websiteJsonLd(payload: SitePayload): Json {
});
}
-/** 이 페이지가 언제 기준인지. AI 검색은 신선도를 본다 — 없으면 오래된 페이지로 취급된다. */
+/** 이 페이지가 언제 기준인지. */
export function webPageJsonLd(
payload: SitePayload,
page: {title: string; description: string},
@@ -470,13 +356,12 @@ export function webPageJsonLd(
inLanguage: 'ko-KR',
isPartOf: {'@id': `${siteUrl(payload)}#website`},
about: {'@id': `${siteUrl(payload)}#business`},
- // ★ "누가 썼나" 가 비어 있으면 E-E-A-T 채점에서 통째로 0점이다. 이 페이지의 모든 문장은
- // 사업자가 확인한 값이므로 저자·발행자는 사업장 자신이다 — 사람 이름을 지어내지 않는다.
+ // 사업자가 확인한 값이므로 저자·발행자는 사업장 자신이다 — 사람 이름을 지어내지 않는다.
author: {'@id': `${siteUrl(payload)}#business`},
publisher: {'@id': `${siteUrl(payload)}#business`},
datePublished: payload.site.publishedAt,
dateModified: payload.site.updatedAt,
- // 음성 답변이 읽어 갈 자리. 상호가 든 h1 과 "예약 전 확인" 단정문 문단이다.
+ // 음성 답변이 읽어 갈 자리.
speakable: {
'@type': 'SpeakableSpecification',
cssSelector: ['h1', '#summary p'],
@@ -484,13 +369,7 @@ export function webPageJsonLd(
});
}
-/**
- * 빵부스러기.
- *
- * ★ 예전에는 "한 장짜리 사이트라 자기 자신뿐"이라며 뺐다. 하지만 이 사이트는 오리진 루트
- * 아래 `/s/` 에 있어서 **상위가 실제로 존재한다** — 홈 → 이 업소. 두 칸짜리라도
- * 크롤러에게 "이 URL 이 이 호스트의 어디에 속하는지"를 알려 주는 값이 있다.
- */
+/** 빵부스러기. */
export function breadcrumbJsonLd(payload: SitePayload): Json {
const origin = payload.site.origin.replace(/\/+$/, '');
return {
@@ -504,13 +383,8 @@ export function breadcrumbJsonLd(payload: SitePayload): Json {
};
}
-/** 한 페이지에 실릴 JSON-LD 전부. null 은 걸러진다. */
-/**
- * 이 사이트가 내보내는 구조화 데이터 전부.
- *
- * ★ FAQPage 는 조건 없이 싣는다. 예전에는 페이지마다 실을지 골랐는데, 이제 한 장이라
- * FAQ 가 있으면 그 한 장에 있는 것이다.
- */
+/** 한 페이지에 실릴 JSON-LD 전부. */
+/** 이 사이트가 내보내는 구조화 데이터 전부. */
export function collectJsonLd(
payload: SitePayload,
page: {title: string; description: string},
diff --git a/solution/site/src/seo/llms.ts b/solution/site/src/seo/llms.ts
index 6825db9..3ac308c 100644
--- a/solution/site/src/seo/llms.ts
+++ b/solution/site/src/seo/llms.ts
@@ -9,19 +9,7 @@ import {
} from '@o2o/shared';
import {BOOKING_CHANNELS, SCHEMA_TYPE, UNIT_SPEC, unitBaseRate} from './jsonld';
-/**
- * llms.txt — LLM 이 이 가게를 설명할 때 쓸 사실 목록.
- *
- * HTML 을 파싱하지 않고도 한 파일에서 필요한 사실을 다 얻게 한다.
- * (llmstxt.org 의 마크다운 관례를 따르되, 내용은 우리 규칙을 따른다.)
- *
- * ★ 규칙 세 가지.
- * 1) 확인된 fact 만 쓴다 — selectPublishable() 을 통과하지 못한 값은 한 줄도 안 들어간다.
- * 2) 사실만 쓴다. "아름다운", "최고의" 같은 형용사를 쓰지 않는다.
- * LLM 이 그대로 인용하면 그게 광고가 되고, 광고는 신뢰를 깎는다.
- * 3) 모르는 것은 "정보 없음"이라고 쓴다. 빈칸으로 두면 LLM 이 추측으로 메운다 —
- * 그 추측이 헛걸음을 만든다.
- */
+/** llms.txt — LLM 이 이 가게를 설명할 때 쓸 사실 목록. */
export function renderLlmsTxt(payload: SitePayload): string {
const {place, site, narrative} = payload;
const url = (...parts: string[]) => joinUrl(site.origin, site.basePath, ...parts);
@@ -88,8 +76,6 @@ export function renderLlmsTxt(payload: SitePayload): string {
}
// ── FAQ — LLM 이 가장 잘 인용하는 부분 ─────────────────
- // ★ 문의 안내(TEMPLATE)는 뺀다. 규칙 3("모르는 것은 정보 없음") 과 달리 질문 자체가 우리가 붙인 것이다.
- // 화면에 없는 것을 llms.txt 에만 적지 않는다 — 같은 이유로 JSON-LD 도 섹션이 켜졌을 때만 낸다.
const faqOnScreen = payload.theme.sections.some((section) => section.id === 'faq' && section.enabled);
const faqs = faqOnScreen ? selectAnsweredFaqs(payload.faqs) : [];
if (faqs.length > 0) {
@@ -142,15 +128,7 @@ export function renderLlmsTxt(payload: SitePayload): string {
return lines.join('\n');
}
-/**
- * 예약 — 숙박에서 가장 많이 묻는 질의("어떻게 예약해요 / 얼마예요")의 답을 한 블록에 모은다.
- *
- * ★ 이용 정보·객실 절에 흩어져 있는 값을 한 번 더 쓰는 것이지만, LLM 은 이 파일을 위에서부터
- * 읽고 답을 만든다. 예약 경로가 "공식 채널" 절 맨 아래에만 있으면 답에 안 실린다.
- * ★ **재고와 결제를 우리가 갖지 않는다는 사실을 명시한다.** 이 문장이 없으면 LLM 이
- * "공식 홈페이지에서 바로 예약할 수 있다"고 답한다 — 그건 거짓이고, 손님은 헛걸음한다.
- * ★ 예약 채널은 확정된 것만이다. 확정 전 URL 은 동명 업소의 예약 페이지일 수 있다.
- */
+/** 예약 — 숙박에서 가장 많이 묻는 질의("어떻게 예약해요 / 얼마예요")의 답을 한 블록에 모은다. */
function pushStayBooking(lines: string[], payload: SitePayload) {
if (payload.place.category !== PlaceCategory.LODGING) return;
@@ -183,7 +161,7 @@ function pushFact(lines: string[], label: string, value: string | null | undefin
lines.push(`- ${label}: ${value?.trim() ? value : '정보 없음'}`);
}
-/** bool fact 는 "true/false" 로 두면 LLM 이 문장으로 못 옮긴다. 한국어로 바꿔 준다. */
+/** bool fact 는 "true/false" 로 두면 LLM 이 문장으로 못 옮긴다. */
function formatValue(type: string, value: string): string {
if (type !== 'bool') return value;
if (value === 'true' || value === '1') return '가능';
diff --git a/solution/site/src/seo/meta.test.ts b/solution/site/src/seo/meta.test.ts
index 0f8b697..f93e3db 100644
--- a/solution/site/src/seo/meta.test.ts
+++ b/solution/site/src/seo/meta.test.ts
@@ -1,10 +1,4 @@
-/**
- * SiteOntology 키워드 → 제목 · 메타 키워드.
- *
- * ★ 백엔드(services/seo_keywords)가 이미 이 가게 자료로 거른 값만 payload.seo 로 온다.
- * 여기서 지키는 것은 **자리**다 — 키워드가 없거나 제목이 너무 짧아지면 예전 제목으로 떨어져야 하고,
- * 키워드가 없는 옛 payload(목업 포함)는 head 가 한 글자도 바뀌면 안 된다.
- */
+/** SiteOntology 키워드 → 제목 · 메타 키워드. */
import {describe, expect, it} from 'vitest';
import type {SitePayload} from '@o2o/shared';
diff --git a/solution/site/src/seo/meta.ts b/solution/site/src/seo/meta.ts
index ed5fca4..5e76f51 100644
--- a/solution/site/src/seo/meta.ts
+++ b/solution/site/src/seo/meta.ts
@@ -5,13 +5,7 @@ import {
type SitePayload,
} from '@o2o/shared';
-/**
- * 메타.
- *
- * description 은 지어내지 않는다 — 확인된 fact 로 단정문을 조립한다.
- * AI 검색은 이 한 줄을 그대로 인용하는 일이 많아서, 문장 하나가 틀리면
- * 그 오답이 여러 엔진에 퍼진다.
- */
+/** 메타. */
export interface PageMeta {
title: string;
@@ -19,7 +13,7 @@ export interface PageMeta {
canonical: string;
ogImage?: string;
ogImageAlt?: string;
- /** ``. SiteOntology 키워드가 없으면 비어 태그를 만들지 않는다. */
+ /** ``. */
keywords?: string;
}
@@ -27,18 +21,13 @@ function url(payload: SitePayload, ...parts: string[]): string {
return joinUrl(payload.site.origin, payload.site.basePath, ...parts);
}
-/**
- * 사람이 검색창에 치는 지역 이름.
- *
- * 읍·면이 있으면 그걸 먼저 쓴다 — "제주시 스테이" 보다 "애월 스테이" 로 검색하기 때문이다.
- * 셋 다 없으면 undefined 를 돌려준다(지어내지 않는다).
- */
+/** 사람이 검색창에 치는 지역 이름. */
export function localName(payload: SitePayload): string | undefined {
const {addressSubLocality, addressLocality, addressRegion} = payload.place;
return addressSubLocality ?? addressLocality ?? addressRegion ?? undefined;
}
-/** 대표 이미지 — og:image. 대체 텍스트가 있는 것만 쓴다. */
+/** 대표 이미지 — og:image. */
export function primaryImage(payload: SitePayload) {
return (
payload.media.find((m) => m.isPrimary && m.alt?.trim()) ??
@@ -46,23 +35,12 @@ export function primaryImage(payload: SitePayload) {
);
}
-/**
- * 사실만으로 만드는 한 줄 요약.
- *
- * "제주 애월에 있는 달빛스테이 제주입니다. 체크인 16:00, 체크아웃 11:00. 반려동물 동반 불가."
- * 형용사를 붙이지 않는다 — 확인된 값이 그대로 문장이 된다.
- */
+/** 사실만으로 만드는 한 줄 요약. */
export function factualSummary(payload: SitePayload): string {
return factSentences(payload).join(' ');
}
-/**
- * 확인된 fact 를 문장 단위로 쪼개 돌려준다.
- *
- * ★ 조각으로 돌려주는 이유: description 은 짧아도 감점이다(권장 50~160자).
- * 사장님이 쓴 한 줄 소개가 39자면 뒤에 사실 문장을 하나씩 붙여 길이를 채워야 하는데,
- * 통짜 문자열이면 어디서 끊어 붙일지 알 수 없다.
- */
+/** 확인된 fact 를 문장 단위로 쪼개 돌려준다. */
function factSentences(payload: SitePayload): string[] {
const {place, facts} = payload;
const parts: string[] = [];
@@ -92,7 +70,7 @@ function factSentences(payload: SitePayload): string[] {
return parts;
}
-/** 160자 안쪽으로 줄인다. 단어 중간에서 자르지 않는다. */
+/** 160자 안쪽으로 줄인다. */
function clamp(text: string, max = 160): string {
if (text.length <= max) return text;
const cut = text.slice(0, max);
@@ -100,13 +78,7 @@ function clamp(text: string, max = 160): string {
return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
}
-/**
- * description 을 권장 길이(50~160자)로 맞춘다.
- *
- * ★ 짧은 것도 감점이다. 사장님의 한 줄 소개는 대개 30~40자라 그대로 내보내면
- * "39자 — 권장 50~160자" 로 걸린다. 지어내서 늘리지 않고, 이미 확인된 fact 문장을
- * 50자를 넘을 때까지 하나씩 붙인다. 소개가 이미 50자를 넘으면 손대지 않는다.
- */
+/** description 을 권장 길이(50~160자)로 맞춘다. */
function describe(payload: SitePayload, min = 50): string {
const summary = payload.narrative.summary?.trim();
const parts: string[] = [];
@@ -119,10 +91,7 @@ function describe(payload: SitePayload, min = 50): string {
return clamp(parts.join(' '));
}
-/**
- * 사람이 검색창에 치는 업종어. "군산 숙소" 처럼 상호를 모르는 질의에 걸리는 자리다.
- * 홍보어(맛집·명소)는 쓰지 않는다 — 확인할 수 없는 주장이다.
- */
+/** 사람이 검색창에 치는 업종어. */
const CATEGORY_WORD: Record = {
[PlaceCategory.LODGING]: '숙소',
[PlaceCategory.CAFE]: '카페',
@@ -133,17 +102,7 @@ const CATEGORY_WORD: Record = {
/** Bing 웹마스터도구가 이보다 짧은 제목을 Error(Title too short)로 잡는다. */
const MIN_TITLE_LENGTH = 15;
-/**
- * `<상호> · <지역> <업종>`.
- *
- * ★ `<상호> · <시군구>` 였다. "스테이,머뭄 · 군산시" 는 12자인데 Bing 웹마스터도구가
- * **15자 미만을 Error(Title too short)** 로 잡는다. 광역시도와 업종어를 같이 실어
- * 길이도 채우고 "군산 숙소" 같은 질의에도 걸리게 한다.
- * ★ SiteOntology 대표 키워드가 오면 `<상호> · <키워드>` 다 (2026-09-14).
- * "숙소" 는 업종별 고정어라 "군산 숙소" 질의에만 걸린다. 키워드는 백엔드가 이 가게 자료로 거르고
- * 시·군 이름을 품은 것만 보내므로(services/seo_keywords) 지역 신호는 그대로 남는다.
- * 붙였더니 15자보다 짧으면 예전 제목으로 떨어진다.
- */
+/** `<상호> · <지역> <업종>`. */
function homeTitle(payload: SitePayload): string {
const {place} = payload;
const keyword = payload.seo?.titleKeyword?.trim();
@@ -159,7 +118,7 @@ function homeTitle(payload: SitePayload): string {
return [place.name, where].filter(Boolean).join(' · ');
}
-/** `` 값. 키워드가 없으면 undefined — head 가 태그를 만들지 않는다. */
+/** `` 값. */
export function metaKeywords(payload: SitePayload): string | undefined {
const words = (payload.seo?.keywords ?? []).map((word) => word.trim()).filter(Boolean);
return words.length > 0 ? words.join(', ') : undefined;
diff --git a/solution/site/src/seo/robots.ts b/solution/site/src/seo/robots.ts
index a75d653..da6c329 100644
--- a/solution/site/src/seo/robots.ts
+++ b/solution/site/src/seo/robots.ts
@@ -1,13 +1,5 @@
-/**
- * AI 크롤러를 **명시적으로 허용**하는 robots.txt.
- *
- * 보통 사이트들은 이 봇들을 막는다(학습 데이터로 쓰이는 게 싫어서).
- * 우리는 반대다 — 이 서비스의 목표가 "AI 검색이 이 가게를 공식 홈페이지 기준으로
- * 설명하게 만드는 것"이라, 읽히지 않으면 존재 이유가 없다.
- * 기본값(=명시 없음)에 맡기지 않고 이름을 하나씩 적는 이유는,
- * 일부 봇이 와일드카드보다 자기 이름 규칙을 우선으로 보기 때문이다.
- */
+/** AI 크롤러를 **명시적으로 허용**하는 robots.txt. */
const AI_CRAWLERS = [
'GPTBot', // OpenAI 학습·검색
'OAI-SearchBot', // ChatGPT search
@@ -33,14 +25,7 @@ const SEARCH_CRAWLERS = [
'Daumoa', // 다음
];
-/**
- * 앱 전용 경로 — 크롤러가 볼 것이 없는 자리.
- *
- * ★ 로그인해야 내용이 생기거나(`/sites` `/account`), 도구 화면이라(`/builder`) 색인할
- * 내용이 아예 없다. 프리렌더를 켠 뒤 이 경로들은 빈 SPA 폴백을 받는데, 그걸 긁히면
- * 호스트 전체에 "내용 없는 페이지" 신호가 쌓인다.
- * ★ `/showcase` 는 막지 않는다 — 프리렌더 대상이고 발행 사례를 담는다.
- */
+/** 앱 전용 경로 — 크롤러가 볼 것이 없는 자리. */
const APP_ONLY_PATHS = ['/builder', '/login', '/signup', '/sites', '/account'];
/** 크롤러 허용 블록 — 루트용과 사이트용이 같은 목록을 쓴다. */
@@ -53,16 +38,7 @@ function allowBlocks(): string[] {
return blocks;
}
-/**
- * **오리진 루트의** robots.txt — 실제로 읽히는 유일한 robots.txt.
- *
- * ★ robots.txt 는 오리진 루트(`https://host/robots.txt`)에서만 읽힌다(RFC 9309).
- * 발행 사이트는 `/s//` 아래에 있어서 사이트별 robots.txt 는 **아무도 읽지 않는다** —
- * AI 크롤러를 아무리 명시 허용해도 파일이 그 자리에 있으면 효과가 0이다.
- * 그래서 프리렌더가 사이트를 다 구운 뒤 여기서 루트 파일을 한 장 더 쓴다.
- *
- * ★ Sitemap 도 여기서만 전달된다. 사이트별 sitemap.xml 은 사이트맵 인덱스가 묶는다.
- */
+/** **오리진 루트의** robots.txt — 실제로 읽히는 유일한 robots.txt. */
export function renderRootRobotsTxt(origin: string): string {
const base = origin.replace(/\/+$/, '');
return [
diff --git a/solution/site/src/seo/security.ts b/solution/site/src/seo/security.ts
index a195174..b165f41 100644
--- a/solution/site/src/seo/security.ts
+++ b/solution/site/src/seo/security.ts
@@ -1,22 +1,10 @@
-/**
- * `/.well-known/security.txt` (RFC 9116).
- *
- * ★ 왜 굽나
- * 지금은 이 경로가 없어서 nginx 의 SPA 폴백이 사장님 앱 index.html 을 200 으로 준다.
- * 진단 도구는 200 을 받고 "security.txt 는 있는데 Contact·Expires 가 없다"로 읽는다 —
- * 404 보다 나쁜 상태다. 파일을 진짜로 놓아 규격대로 답하게 한다.
- *
- * ★ Expires 는 규격상 **필수**이고 지나면 파일이 무효다. 그래서 굽는 시점 기준으로 다시
- * 계산한다 — 발행할 때마다 갱신되므로 사람이 손대야 하는 날짜가 코드에 남지 않는다.
- * 1년이 아니라 180일인 이유: 규격 권고가 "1년 이내"고, 반년이면 발행이 뜸한 호스트도
- * 만료 전에 한 번은 다시 굽힌다.
- */
+/** `/.well-known/security.txt` (RFC 9116). */
const EXPIRES_DAYS = 180;
export interface SecurityTxtOptions {
- /** `mailto:` 또는 `https:` URL. 없으면 호스트의 security@ 주소로 만든다. */
+ /** `mailto:` 또는 `https:` URL. */
contact?: string;
- /** 굽는 시각. 테스트가 고정값을 넣는다. */
+ /** 굽는 시각. */
now?: Date;
}
diff --git a/solution/site/src/seo/sitemap.ts b/solution/site/src/seo/sitemap.ts
index fdad094..f6c4ef1 100644
--- a/solution/site/src/seo/sitemap.ts
+++ b/solution/site/src/seo/sitemap.ts
@@ -1,22 +1,13 @@
import {escapeHtml} from './head';
export interface SiteEntry {
- /** 사이트 홈 주소. 사이트가 한 장이라 이게 그 사이트의 유일한 URL 이다. */
+ /** 사이트 홈 주소. */
loc: string;
/** 마지막 발행 시각(ISO). */
lastmod?: string;
}
-/**
- * 오리진 루트의 사이트맵 — 이 호스트의 **모든 사이트**를 한 파일에 담는다.
- *
- * ★ 예전에는 사이트마다 `sitemap.xml` 을 굽고 루트에 사이트맵 인덱스를 뒀다.
- * 사이트가 한 장이 되면서 그 구조가 낭비가 됐다 — URL 한 줄짜리 파일이 사이트 수만큼
- * 생기고, 인덱스는 그걸 다시 1,000줄로 가리킨다. 크롤러 왕복만 두 배가 된다.
- *
- * ★ 규격 상한은 파일당 URL 50,000개 · 압축 전 50MB 다. 그 선을 넘으면 그때
- * 사이트맵을 여러 개로 쪼개고 인덱스를 되살린다(지금 구조에서 어렵지 않다).
- */
+/** 오리진 루트의 사이트맵 — 이 호스트의 **모든 사이트**를 한 파일에 담는다. */
export function renderSiteUrlset(entries: SiteEntry[]): string {
const urls = entries
.map((entry) =>
diff --git a/solution/site/src/seo/verify.test.ts b/solution/site/src/seo/verify.test.ts
index 8152f7a..ca88b95 100644
--- a/solution/site/src/seo/verify.test.ts
+++ b/solution/site/src/seo/verify.test.ts
@@ -1,19 +1,9 @@
-/**
- * 구조화 데이터 ↔ 화면 값 대조 — ★ 절대규칙 3.
- *
- * 이 검증기가 절대 하면 안 되는 것:
- * 1. JSON-LD 에만 있고 화면에 없는 값을 통과시키는 것 (검색엔진에 거짓을 심는다)
- * 2. JSON-LD 가 **자기 자신**(script 블록)을 근거로 통과하는 것 (검사가 무의미해진다)
- * 3. 화면이 뒷받침하는 사실을 표기 차이만으로 막는 것 (멀쩡한 사이트가 발행 못 한다)
- *
- * ★ 이 검사는 백엔드에 있었지만, 백엔드는 **자기만의 HTML** 을 따로 구워 그걸 봤다.
- * 방문자가 보는 페이지는 이 렌더러가 굽는다 — 검사는 나갈 그 HTML 에 대고 해야 한다.
- */
+/** 구조화 데이터 ↔ 화면 값 대조 — ★ 절대규칙 3. */
import {describe, expect, it} from 'vitest';
import {verifyJsonLd, verifyGeo, visibleText} from './verify';
-/** 사업장 페이지 한 장. 화면에 보이는 값만 본문에 넣는다. */
+/** 사업장 페이지 한 장. */
function page(body: string, head = ''): string {
return [
'',
@@ -54,8 +44,6 @@ describe('visibleText', () => {
describe('verifyJsonLd — 표기 차이로 사실을 막지 않는다', () => {
it('속성의 & 이스케이프를 표기 차이로 흡수한다 — 화면에 있는 이미지였다', () => {
- // 실측(2026-09-07): 쿼리스트링 있는 이미지 URL 을 쓰는 사이트가 전부 발행 불가였다.
- // HTML 속성에서는 & 가 & 로 나가는데 JSON-LD 는 원본 & 를 갖고 있다.
const url = 'https://cdn.example.com/a.jpg?auto=format&fit=crop';
const body = ``;
expect(verifyJsonLd(page(body), [{'@type': 'LodgingBusiness', image: [url]}])).toEqual([]);
@@ -135,7 +123,6 @@ describe('verifyJsonLd — 사실은 통과시킨다', () => {
it('페이지 메타 노드(WebPage·WebSite)는 본문 대조 대상이 아니다', () => {
// name·description·datePublished 는 · 로 나가는 값이지 본문 문장이 아니다.
- // 이걸 본문에서 찾으면 멀쩡한 사이트가 전부 실패한다.
expect(
verifyJsonLd(page(BODY), [
{'@type': 'WebPage', name: '본문에 없는 제목', description: '본문에 없는 설명'},
@@ -155,7 +142,7 @@ describe('verifyJsonLd — 사실은 통과시킨다', () => {
describe('verifyJsonLd — 합성값', () => {
it('priceRange 는 양끝이 다 보이면 통과한다', () => {
- // 객실 요금에서 계산한 요약이다. 화면에는 20,000원·60,000원 이 각각 나온다.
+ // 객실 요금에서 계산한 요약이다.
expect(
verifyJsonLd(page(BODY), [{'@type': 'LodgingBusiness', priceRange: '20,000원 ~ 60,000원'}]),
).toEqual([]);
diff --git a/solution/site/src/seo/verify.ts b/solution/site/src/seo/verify.ts
index abc4ec1..d110f27 100644
--- a/solution/site/src/seo/verify.ts
+++ b/solution/site/src/seo/verify.ts
@@ -1,66 +1,33 @@
-/**
- * 구조화 데이터 ↔ 화면 값 대조. **★ 절대규칙 3 을 코드로 강제하는 자리다.**
- *
- * JSON-LD 는 AI 검색이 읽는 값이고 화면은 사람이 읽는 값이다. 둘이 다르면
- * '검색엔진에만 다른 말을 하는' 상태가 된다 — 클로킹으로 취급될 수 있고, 무엇보다 거짓이다.
- *
- * ★ 왜 여기(렌더러 안)에 있나
- * 예전에는 백엔드가 **자기만의 HTML 을 따로 구워** 그걸 대조했다. 방문자가 보는 페이지는
- * 이 렌더러가 굽는데, 검사는 아무도 안 보는 페이지에 대고 하고 있었다 — 두 렌더러가
- * 어긋나는 순간 게이트는 통과인데 실제 페이지는 다른 내용이 된다.
- * 검사는 **나갈 바로 그 HTML** 에 대고 해야 의미가 있다.
- *
- * ★ 검사 방식
- * JSON-LD 의 모든 스칼라 값이 화면 텍스트(또는 src/href)에 나타나는지 본다.
- * 나타나지 않으면 "화면에 없는 것을 구조화 데이터가 주장하고 있다" 는 뜻이고, 그게 거짓이다.
- */
+/** 구조화 데이터 ↔ 화면 값 대조. */
import type {Json} from './jsonld';
-/** 화면에 텍스트로 나타나지 않는 구조·메타 값. 대조 대상에서 제외한다. */
+/** 화면에 텍스트로 나타나지 않는 구조·메타 값. */
const STRUCTURAL = new Set([
'@context',
'@type',
'@id',
'priceCurrency',
- // ★ ㎡의 ISO 코드('MTK'). priceCurrency 와 같은 성격이라 화면에 나올 값이 아니다 —
- // 빠져 있어서 객실 면적(room_size)이 있는 사업장은 전부 발행이 막혔다(실측: 가은채 객실 12개).
- // ★ 사람이 읽는 단위 표기(`unitText`)는 여기 넣지 않는다 — 그건 화면에 있어야 하는 말이다.
+ // ㎡의 ISO 코드('MTK').
'unitCode',
- // ★ units.length 로 만든 파생 카운트다. 화면에는 객실 카드가 그 개수만큼 있을 뿐
- // '12' 라는 숫자가 글자로 있지는 않다. 객실이 2~3개인 사업장만 우연히 통과했다.
+ // units.length 로 만든 파생 카운트다.
'numberOfRooms',
'addressCountry',
'inLanguage',
- // 좌표는 지도 핀용 메타지 본문에 쓸 값이 아니다. 대신 payload 와 직접 대조한다(verifyGeo).
+ // 좌표는 지도 핀용 메타지 본문에 쓸 값이 아니다.
'latitude',
'longitude',
]);
-/** 이름과 값을 따로 대조하는 서브트리. 일반 순회에서는 건너뛴다. */
+/** 이름과 값을 따로 대조하는 서브트리. */
const SPECIAL_SUBTREE = new Set(['amenityFeature', 'makesOffer', 'geo']);
-/**
- * **페이지를 설명하는** 노드. 본문 대조 대상이 아니다.
- *
- * ★ 이게 왜 필요한가
- * WebPage 의 name·description·datePublished 는 `` 과 `` 로 나가는 값이지
- * 본문에 찍히는 문장이 아니다. 이걸 본문에서 찾으면 멀쩡한 사이트가 전부 실패한다.
- * 대조해야 할 것은 **사업장에 대한 주장**(상호·주소·전화·가격·편의시설)과 FAQ 문답이다.
- * 그것들이 화면에 없으면서 구조화 데이터에만 있으면 그게 거짓이다.
- */
+/** **페이지를 설명하는** 노드. */
const PAGE_META_TYPES = new Set(['WebPage', 'WebSite', 'BreadcrumbList', 'ImageObject']);
-/**
- * **합성값** 속성. 통째로는 화면에 없고, 구성 요소가 각각 화면에 있으면 통과다.
- *
- * 예: priceRange `'20,000원 ~ 60,000원'` 는 객실 요금에서 계산한 요약이다. 화면에는
- * `20,000원` 과 `60,000원` 이 각 객실 카드에 따로 나온다 — 두 끝값이 다 보이면
- * 그 범위는 화면이 뒷받침하는 주장이다. 통짜 비교를 고집하면 사실인 값이 발행을 막는다.
- * ★ 대신 **구성 요소가 하나라도 화면에 없으면 실패**다 — 검사를 느슨하게 하는 게 아니다.
- */
+/** **합성값** 속성. */
const COMPOSITE_PROPS = new Set(['priceRange']);
-/** 합성값을 구성 요소로 쪼갠다(범위 구분자·쉼표 기준). 빈 조각은 버린다. */
+/** 합성값을 구성 요소로 쪼갠다(범위 구분자·쉼표 기준). */
function splitComposite(token: string): string[] {
return token
.split(/\s*[~–—]\s*|\s+-\s+/)
@@ -88,12 +55,7 @@ function unescapeHtml(value: string): string {
.replace(/([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16)));
}
-/**
- * 화면에 실제로 보이는 텍스트.
- *
- * ★ script(= JSON-LD 자신)와 style 을 먼저 걷어낸다. 이걸 안 하면 JSON-LD 가
- * 자기 자신을 근거로 통과해버려 검사가 통째로 무의미해진다.
- */
+/** 화면에 실제로 보이는 텍스트. */
export function visibleText(html: string): string {
const body = html.replace(SCRIPT_RE, ' ').replace(STYLE_RE, ' ').replace(TAG_RE, ' ');
return unescapeHtml(body).replace(/\s+/g, ' ').trim();
@@ -125,12 +87,12 @@ function* iterScalars(node: unknown, path: string[] = []): Generator<[string[],
yield [path, node as Scalar];
}
-/** 주소로 볼 값인지 — 절대 URL 이거나 루트 절대경로. 본문 텍스트가 아니라 속성에 있다. */
+/** 주소로 볼 값인지 — 절대 URL 이거나 루트 절대경로. */
function isUrlLike(token: string): boolean {
return token.startsWith('http://') || token.startsWith('https://') || token.startsWith('/');
}
-/** 절대 URL 에서 오리진을 뗀 경로. 절대경로는 그대로 돌려준다. */
+/** 절대 URL 에서 오리진을 뗀 경로. */
function pathOf(token: string): string {
if (token.startsWith('/')) return token;
try {
@@ -141,7 +103,7 @@ function pathOf(token: string): string {
}
}
-/** 숫자를 화면 표기(1,000 단위 구분)로. 화면이 그렇게 그리므로 대조도 같은 표기를 본다. */
+/** 숫자를 화면 표기(1,000 단위 구분)로. */
function asShown(value: Scalar): string[] {
if (typeof value === 'number' && Number.isFinite(value)) {
return [String(value), value.toLocaleString('ko-KR')];
@@ -149,32 +111,16 @@ function asShown(value: Scalar): string[] {
return [String(value)];
}
-/**
- * JSON-LD 값이 전부 화면에 나타나는지 대조한다. 불일치 목록을 돌려준다(비면 통과).
- *
- * @param html 실제로 나갈 페이지 HTML
- * @param nodes 그 페이지에 실린 JSON-LD 노드 전부
- */
+/** JSON-LD 값이 전부 화면에 나타나는지 대조한다. */
export function verifyJsonLd(html: string, nodes: Json[]): string[] {
const text = visibleText(html);
- /**
- * URL 대조용 사본 — 엔티티를 되돌린 HTML.
- *
- * ★ 왜 필요한가 (실측 2026-09-07, 데모 payload)
- * `` 는 HTML 로 나갈 때 `&` 가 `&` 로 이스케이프된다.
- * JSON-LD 의 `image` 는 원본 `&` 를 갖고 있으므로 원본 HTML 문자열에서는 절대 안 찾아진다 —
- * **화면에 실제로 있는 이미지가 "화면에 없다"로 잡혀** 발행이 막혔다. 쿼리스트링 있는
- * 이미지 URL 을 쓰는 사이트는 전부 이 오탐에 걸린다.
- * 숫자 표기 차이를 `asShown()` 으로 흡수하는 것과 같은 이유다 — **표기 차이는 거짓이 아니다.**
- * ★ 반대로 느슨해지지는 않는다: 되돌린 사본에서도 못 찾으면 그대로 실패다.
- */
+ /** URL 대조용 사본 — 엔티티를 되돌린 HTML. */
const unescaped = unescapeHtml(html);
const problems: string[] = [];
const seen = new Set();
const report = (message: string) => {
- // 같은 값이 여러 노드에 반복되면(name 은 사업장·WebSite·WebPage 에 다 있다)
- // 같은 사유가 여러 번 쌓인다 — 운영자가 볼 목록이지 통계가 아니다.
+ // 같은 값이 여러 노드에 반복되면(name 은 사업장·WebSite·WebPage 에 다 있다) 같은 사유가 여러 번 쌓인다 — 운영자가 볼 목록이지 통계가 아니다.
if (seen.has(message)) return;
seen.add(message);
problems.push(message);
@@ -191,16 +137,7 @@ export function verifyJsonLd(html: string, nodes: Json[]): string[] {
const token = String(value);
// URL·이미지는 본문 텍스트가 아니라 요소 속성(src/href)에 있다.
if (isUrlLike(token)) {
- /*
- * ★ 경로로도 대조한다 (2026-09-09)
- * 우리 자산을 가리키는 이미지는 HTML 에 **루트 절대경로**(`/assets/…`)로 박히는데
- * JSON-LD 에는 오리진이 붙은 절대 URL 로 나간다(jsonld `imageUrl`). 토큰을 통째로
- * 찾으면 같은 그림인데 "화면에 없다"가 된다 — 실측(2026-09-09): 사진을 우리 쪽으로
- * 미러한 순간 게이트가 사이트 셋을 다 막았다.
- * 반대 방향(HTML 절대·JSON-LD 상대)도 같은 이유로 함께 본다.
- * ★ `unescaped` 도 같이 본다 — HTML 엔티티로 이스케이프된 자리(& 를 낀 쿼리)는
- * 원문 그대로는 안 잡힌다.
- */
+ /* 경로로도 대조한다 우리 자산을 가리키는 이미지는 HTML 에 **루트 절대경로**(`/assets/…`)로 박히는데 JSON-LD 에는 오리진이 붙은 절대 URL 로 나간다(jsonld `imageUrl`). */
const tokenPath = pathOf(token);
if (
html.includes(token) ||
@@ -216,7 +153,7 @@ export function verifyJsonLd(html: string, nodes: Json[]): string[] {
if (asShown(value).some((shown) => text.includes(shown))) continue;
if (COMPOSITE_PROPS.has(prop)) {
- // 구성 요소가 전부 화면에 있으면 통과. 하나라도 없으면 그 조각을 사유로 남긴다.
+ // 구성 요소가 전부 화면에 있으면 통과.
const parts = splitComposite(token);
const missing = parts.filter((part) => !text.includes(part));
if (parts.length > 1 && missing.length === 0) continue;
@@ -250,12 +187,7 @@ function asArray(value: unknown): Record[] {
return [];
}
-/**
- * 좌표가 payload 와 같은지. 지도 핀이 엉뚱한 데 찍히는 것도 거짓 정보다.
- *
- * 좌표는 화면 텍스트로 나오지 않아 본문 대조로는 확인할 수 없다 —
- * 검사에서 빼는 것과 검사를 안 하는 것은 다르므로, 원본과 직접 맞춘다.
- */
+/** 좌표가 payload 와 같은지. */
export function verifyGeo(nodes: Json[], latitude?: number, longitude?: number): string[] {
const problems: string[] = [];
const TOLERANCE = 1e-6;
diff --git a/solution/site/src/vite-env.d.ts b/solution/site/src/vite-env.d.ts
index a0da0a2..7b11bb9 100644
--- a/solution/site/src/vite-env.d.ts
+++ b/solution/site/src/vite-env.d.ts
@@ -1,9 +1,3 @@
///
-/*
- * ★ 이 렌더러는 **런타임 환경변수를 쓰지 않는다**(site/.env.example).
- * 사이트마다 다른 값은 전부 SitePayload 로 들어온다 — 그래야 payload 하나로 같은 HTML 이
- * 재현된다. `VITE_API_BASE_URL` 선언이 여기 있었는데, 실시간 날씨 조회가 그걸 읽다가
- * 값이 없는 자리에서 구워져 발행본이 `localhost:9800` 을 부르는 사고가 났다
- * (2026-09-09, use-live-weather.ts 주석). 선언 자체를 없애 다시 생기지 않게 한다.
- */
+/* 이 렌더러는 **런타임 환경변수를 쓰지 않는다**(site/.env.example). */
diff --git a/solution/site/vite.config.ts b/solution/site/vite.config.ts
index ad0dbdd..f0dd1a7 100644
--- a/solution/site/vite.config.ts
+++ b/solution/site/vite.config.ts
@@ -3,25 +3,12 @@ import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
-/**
- * 발행 사이트 렌더러.
- *
- * 빌드가 두 번 돈다:
- * build:client → dist/client 브라우저 번들 + manifest(자산 경로를 프리렌더가 읽는다)
- * build:prerender → dist/prerender 프리렌더러 자체를 SSR 번들로 묶는다
- * (별도 TS 로더 없이 경로 별칭·TSX·CSS import 가 해결된다)
- * 그다음 node dist/prerender/prerender.js 가 payload 마다 정적 HTML 을 굽는다.
- *
- * ★ 이 앱은 런타임 서버가 없다. 결과물은 파일뿐이라 어디에 올려도 돈다.
- */
+/** 발행 사이트 렌더러. */
export default defineConfig(({isSsrBuild}) => ({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
- // ★ 별칭이 '@' 가 아니라 '@site' 인 이유(2026-09-09)
- // 빌더 미리보기가 **이 렌더러를 그대로** 쓴다(solution/frontend). 두 앱이 '@' 를
- // 각자 자기 src 로 두면, 여기 컴포넌트를 저쪽에서 불러올 때 내부 import 가
- // 저쪽 src 로 떨어져 조용히 다른 파일을 잡는다. 이름을 갈라 두면 어디서 불러도 같다.
+ // 별칭이 '@' 가 아니라 '@site' 인 이유 빌더 미리보기가 **이 렌더러를 그대로** 쓴다(solution/frontend).
'@site': path.resolve(__dirname, 'src'),
'@o2o/shared': path.resolve(__dirname, '../shared/src'),
},
@@ -31,14 +18,10 @@ export default defineConfig(({isSsrBuild}) => ({
manifest: !isSsrBuild,
emptyOutDir: true,
rollupOptions: isSsrBuild
- ? undefined // --ssr 로 넘긴 엔트리를 그대로 쓴다.
+ ? undefined // -ssr 로 넘긴 엔트리를 그대로 쓴다.
: {input: path.resolve(__dirname, 'index.html')},
},
- // ★ SSR 번들(dist/prerender/prerender.js)에 react·react-dom 등 node_modules 의존성까지
- // 전부 접어 넣는다. 워커 이미지가 "Node 런타임 + 컴파일된 렌더러"만 담게 하려는 것이다
- // (컨테이너 안에서 npm install 을 돌리지 않는다). noExternal 없이는 이 번들이 실행 시점에
- // node_modules 를 다시 찾는다 — 실측: node_modules 를 지우고 돌리면
- // `Cannot find package 'clsx'` 로 즉시 죽는다.
+ // SSR 번들(dist/prerender/prerender.js)에 react·react-dom 등 node_modules 의존성까지 전부 접어 넣는다.
ssr: {
noExternal: true,
},