o2o-site-AEO/solution/backend/router/v1/site/site.py
Mina Choi b1386dd3ce [refactor] solution,nginx: 에디터와 발행본을 한 렌더러로 — 보는 그대로 나간다
에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 —
캔버스는 `builder/canvas/variants/*` 25종, 발행본은 `site/src/sections/*`.
Playwright 로 재 보니 아예 다른 물건이었다(2026-09-09, 1024px):

  발행본 15섹션 · 에디터 12섹션 · 겹치는 건 4개뿐, 이름도 달랐다
  (gallery↔photos · location↔map · guide↔local)
  겹치는 4개조차 높이가 달랐다(info 488↔535 · booking 242↔487 · itinerary 881↔383)

소스를 하나로 모은다. 편집·미리보기 둘 다 발행본 렌더러가 그린다.

**데이터도 한 벌** — `GET /v1/place/{id}/site/preview` 가 발행이 굽는 것과 **같은 함수**
(`build_snapshot` → `to_site_payload`)로 payload 를 만든다. DB 도 파일도 건드리지 않는다.

**왜 iframe 인가** — 컴포넌트만 같게 해서는 안 됐다. 미디어 쿼리는 창 폭을 보는데 실제
사이트 폭은 그 안의 프레임이라, 그리드 컬럼 수가 어긋나 섹션이 두 배씩 길어졌다
(festival 2560→6027 · guide 1168→2168). iframe 은 자체 뷰포트를 가져 발행본과 같은 폭을 본다.
폭만이 아니라 **높이도** 준다 — 히어로가 `clamp(24rem, 62vh, 36rem)` 이라 낮은 iframe 에서는
하한에 걸렸다(384 ↔ 발행본 576). 자리에 안 들어가면 transform 으로 줄인다: 크기는 그대로,
그림만 줄여야 미디어 쿼리가 안 흔들린다.

**색·서체도 한 벌** — `themeVars(payload)` · `fontHref(payload)`. 셸에는 발행본 `<head>` 의
폰트 링크가 없어 글자만 기본 산세리프로 떨어졌다(지오메트리는 같은데 픽셀 차이 92%).

**에디터가 저장된 템플릿을 안 읽던 것** — `applyTheme` 이 섹션·색팔레트는 되살리는데
templateId 를 빠뜨렸다. templateId 는 theme JSON 이 아니라 `sites.template_id` **컬럼**이라
저장 경로가 다른데 읽는 쪽이 theme 만 봤다. 사장님이 '옛 항구' 를 골라 발행해도 다시
들어오면 편집 화면만 흰 바탕·고딕이었다.

**고르기는 iframe 안에서** — 같은 오리진이라 안쪽 문서에 직접 리스너를 건다. 어느 섹션인지는
`data-editor-id` 로 안다(화면 id `gallery` ↔ 설정 id `photos`; `display:contents` 라 레이아웃
무영향). 표시는 outline 이다 — 상자 크기를 바꾸지 않아 발행본과 픽셀이 그대로다.

곁들여 정리한 것
- 켤 수 없는 섹션 둘(`pricing`·`planner`)을 뗐다 — 기본표에도 [+섹션 추가]에도 없고 DB 참조 0건.
- 반대로 `event`(소식)는 기본표가 켜서 **발행되는데** 채울 UI 가 없었다. 명세를 넣는다.
  이 아이템만 프롬프트가 "찾아라" 가 아니라 **"옮겨 적어라"** 다 — 이 가게에서 지금 하는
  일이라 모델이 알 수 없고, 지어내면 손님이 없는 행사를 보고 찾아온다.
- 예약 버튼이 "네이버 예약 예약" 이었다. `{bookingLabel} 예약` 을 13개 파일에서 각자 이어
  붙이고 있었다 — `bookingActionLabel()` 하나로 모은다.
- `solution/site` 의 별칭을 `@` → `@site` 로 옮겼다(60파일 195건). 두 앱이 '@' 를 각자 자기
  src 로 두면 발행본 컴포넌트를 빌더에서 부를 때 **조용히 다른 파일을 잡는다.**

검증(Playwright, 같은 사업장·1024px):
  섹션 15 = 15 · 순서 일치 · **한쪽에만 있는 섹션 0개**
  15개 전부 높이·글자 수·제목이 정확히 같다
  편집·미리보기·발행본 셋 다 --tpl-bg #e4dac0 · Gugi
  `/preview` ↔ 발행본 문서 높이 9029 = 9029, 픽셀 차이 2.88%(축제 카드 지연 로딩 타이밍)
tsc -b 통과 · eslint 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:09:28 +09:00

207 lines
9.7 KiB
Python

from uuid import UUID
from fastapi.responses import JSONResponse
from fastapi import APIRouter, Depends, Query
from common.models.gmodel import PageParams, UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
from services.site_service import SiteService
from .protocol import (
Req_SiteSlug,
Req_SiteStatus,
Req_SiteTemplate,
Req_SiteTheme,
Req_StartBuild,
Res_MySites,
Res_PublishLogs,
Res_Site,
Res_SeoAudit,
Res_SiteSlug,
Res_SiteVersions,
Res_SlugCheck,
Res_StartBuild,
)
# 사이트/발행 라우터. 사업장 하위 리소스이며 회사 스코프는 service 가 사업장 조회로 강제한다.
router = APIRouter(prefix="/v1/place/{place_id}/site", tags=["Site"], responses={404: {"description": "Not found"}})
# ★ 내 사이트 목록은 사업장 하위가 아니라 계정 하위다 — 위 라우터는 접두어에 place_id 가 박혀 있어
# "내 것 전부"가 들어갈 자리가 없다. 라우터 객체를 하나 더 둔다(router.py 에서 같이 등록).
my_router = APIRouter(prefix="/v1/site", tags=["Site"], responses={404: {"description": "Not found"}})
@my_router.get(
path="/list",
response_model=Res_MySites,
summary="내 사이트 목록",
description="로그인한 계정(회사)이 가진 사이트 전부. 아직 사이트가 만들어지지 않은 사업장도 "
"site_id=null 로 함께 내려간다 — 위저드를 걸어오다 만 것을 목록에서 잃지 않게 한다.",
)
async def list_my_sites(
service: SiteService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
pg: PageParams = Depends(),
):
return RemoveNoneResponse(await service.list_my_sites(user_info, pg))
@router.get(
path="",
response_model=Res_Site,
summary="사이트 상태",
description="현재 발행 버전과 재빌드 필요 여부(needs_rebuild). "
"노출값이 바뀐 뒤 다시 빌드하지 않았으면 true — 이 사업장만 재빌드하면 된다.",
)
async def get_site(place_id: UUID, service: SiteService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_site(user_info, str(place_id)))
@router.get(
path="/audit",
response_model=Res_SeoAudit,
summary="SEO/AEO 준비도 진단",
description="현재 확인된 콘텐츠와 최신 빌드를 SEO·AEO 각각 100점으로 채점하고 개선 항목을 반환한다. "
"준비도 점수이며 실제 검색 순위나 AI 인용을 보장하지 않는다.",
)
async def get_seo_audit(
place_id: UUID, service: SiteService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.get_seo_audit(user_info, str(place_id)))
@router.get(
path="/slug/check",
response_model=Res_SlugCheck,
summary="사이트 주소 사용 가능 확인",
description="쓰고 싶은 주소를 미리 본다. 소문자 영문·숫자·하이픈 3~50자만 되고, 예약어와 남이 쓰는 주소는 막힌다. "
"★ 서버가 상호명으로 자동 확정하지 않는다 — 사람이 고른다. "
"못 쓰면 reason(INVALID_LENGTH·INVALID_FORMAT·RESERVED·TAKEN)과 대안 하나(suggestion)를 준다. "
"지금 자기가 쓰는 주소면 available=true 다.",
)
async def check_slug(
place_id: UUID,
service: SiteService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
slug: str = Query(..., description="쓰고 싶은 주소(예: doflo)"),
):
return RemoveNoneResponse(await service.check_slug(user_info, str(place_id), slug))
@router.post(
path="/slug",
response_model=Res_SiteSlug,
summary="사이트 주소 확정",
description="주소를 sites.domain 에 저장한다(사이트 행이 없으면 만든다). "
"검증은 check 와 같은 규칙으로 서버가 다시 한다 — 클라이언트 검증을 믿지 않는다. "
"★ 이미 발행된 사이트의 주소는 바꿀 수 없다(SITE_SLUG_LOCKED) — "
"색인된 주소가 바뀌면 AI 검색이 잡아 둔 페이지가 404 가 된다.",
)
async def set_slug(
place_id: UUID, req: Req_SiteSlug, service: SiteService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.set_slug(user_info, str(place_id), req))
@router.post(
path="/template",
response_model=Res_Site,
summary="템플릿(디자인) 선택 저장",
description="위저드에서 고른 템플릿을 sites.template_id 에 저장한다(사이트 행이 없으면 만든다). "
"★ 서버는 값을 검증하지 않는다 — 템플릿 목록은 프론트가 소유한다. 길이(100자)만 막는다. "
"★ 주소와 달리 발행 뒤에도 바꿀 수 있다: 디자인이 바뀌어도 URL 은 그대로라 색인이 깨지지 않는다. "
"이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다(needs_rebuild=true).",
)
async def set_template(
place_id: UUID,
req: Req_SiteTemplate,
service: SiteService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
):
return RemoveNoneResponse(await service.set_template(user_info, str(place_id), req))
@router.post(
path="/theme",
response_model=Res_Site,
summary="디자인(색·서체·섹션) 저장",
description="에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다"
"(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: "
"{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"look\":{...},\"colorPaletteId\":\"...\","
"\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\",\"body\",\"data\"}]}}. "
"★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). "
"★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. "
"직렬화 크기(64KB)만 막는다. "
"★ templateId 는 여기 담지 않는다 — sites.template_id 와 POST /template 이 담당한다. "
"★ colorPaletteId 는 에디터 복원 전용이라 저장·반환만 하고 발행 payload 에는 싣지 않는다. "
"★ 빈 값({})을 보내면 NULL 로 되돌아가 업종 기본 색·서체·섹션으로 떨어진다. "
"★ 템플릿과 같이 발행 뒤에도 바꿀 수 있다(디자인이 바뀌어도 URL 은 그대로다). "
"이미 발행된 사이트면 재빌드가 필요하다는 표시로 content_updated_at 을 찍는다"
"(needs_rebuild=true).",
)
async def set_theme(
place_id: UUID,
req: Req_SiteTheme,
service: SiteService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
):
return RemoveNoneResponse(await service.set_theme(user_info, str(place_id), req))
@router.post(
path="/build",
response_model=Res_StartBuild,
summary="정적 빌드(비동기)",
description="스냅샷을 박제해 HTML + JSON-LD 를 만들고 발행 검수 게이트를 돌린다. "
"publish=true 면 통과 시 바로 발행한다. ★ 게이트를 우회하는 옵션은 없다.",
)
async def start_build(
place_id: UUID, req: Req_StartBuild, service: SiteService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.start_build(user_info, str(place_id), req))
@router.get(
path="/preview",
summary="에디터 미리보기 payload — 발행본과 같은 것",
description="발행이 굽는 것과 **같은 함수**로 만든 SitePayload 를 그대로 준다. "
"미리보기가 이 하나만 먹으면 캔버스와 발행본이 갈릴 자리가 없다. "
"★ DB 도 파일도 건드리지 않는다 — 버전을 만들지 않으므로 눌러도 발행 이력이 안 쌓인다.",
)
async def site_preview(
place_id: UUID,
service: SiteService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
):
payload = await service.preview_payload(user_info, str(place_id))
if payload is None:
return JSONResponse(status_code=404, content={"detail": "사업장을 찾지 못했습니다"})
return JSONResponse(content=payload)
@router.get(path="/version/list", response_model=Res_SiteVersions, summary="빌드 버전 목록")
async def list_versions(place_id: UUID, service: SiteService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.list_versions(user_info, str(place_id)))
@router.get(
path="/log/list",
response_model=Res_PublishLogs,
summary="발행 기록",
description="발행 시도와 거부 사유. 게이트가 막았으면 무엇이 문제였는지 detail 에 남는다.",
)
async def list_logs(place_id: UUID, service: SiteService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.list_logs(user_info, str(place_id)))
@router.post(
path="/status",
response_model=Res_Site,
summary="발행 상태 전이(중지·재개·내림)",
description="★ 해지는 삭제가 아니라 상태 전이다. 색인된 페이지를 갑자기 404 로 만들면 "
"AI 검색이 그 자리를 다시 OTA 로 채운다.",
)
async def change_status(
place_id: UUID, req: Req_SiteStatus, service: SiteService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.change_status(user_info, str(place_id), req))