o2o-site-AEO/solution/backend/router/v1/site/protocol.py
Mina Choi e8cda02a4b [feat] solution/backend,postgres-init: 발행 썸네일 저장 + 공개 쇼케이스 목록 — 랜딩이 실물을 걸 자리
랜딩의 "이렇게 나옵니다" 섹션이 걸 그림이 없었다. 발행은 되는데 그 사이트가
어떻게 생겼는지 밖에서 알 방법이 payload 안에만 있었다.

★ 스크린샷이 아니라 그 사이트의 대표 사진(og:image)이다. 헤드리스 브라우저는
  봇 탐지 우회 우려로 영구 금지돼 있고(DECISIONS 1-1), 워커(python:slim)·
  프리렌더(node:alpine) 어디에도 Chromium 이 없다.

- site_thumbnail: 대표 사진을 받아 <prefix>/thumbs/<slug>.<ext> 로 올린다.
  s/<slug>/ 안에 두지 않는 이유 — _remove_stale_site_files 가 매 발행마다
  그 경로를 프리렌더 산출물로 통째로 교체해 조용히 지운다
- site_payload: primary_media()·publish_origin()·region_label() 공개.
  isPrimary 계산을 한 곳으로 모아 og:image 와 썸네일이 갈릴 수 없게 했다
- build_service: azure publish 직후·IndexNow 전에 저장. 실패해도 발행은 그대로
  (payload 와 같은 원칙). thumbnail_url 은 발행 상태 전이 UPDATE 에 합쳐 1회
- GET /v1/showcase: 인증 없음. 발행된 사이트만, place_id·전화·상세주소는 안 나간다
- conftest: fake_renderer 가 늘 ok=True 라 NO_UNIQUE_CONTENT 되짚기 경로가
  통째로 안 돌고 있었다(기존에 깨져 있던 테스트 4건 포함 수정)

전체 562 passed
2026-09-03 09:41:10 +09:00

226 lines
9.0 KiB
Python

import uuid
from datetime import datetime
from typing import Any, Optional
from pydantic import ConfigDict
from common.enums import (
BuildStatus,
JobStatus,
PlaceCategory,
PublishAction,
PublishRejectReason,
PublishResult,
SiteStatus,
)
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
class SiteProtocol(WebPacketProtocol):
pass
class Req_StartBuild(SiteProtocol):
"""정적 빌드 시작.
publish=true 면 발행 검수 게이트를 통과했을 때 바로 발행까지 한다.
★ 게이트를 통과하지 못하면 발행되지 않는다 — 우회 옵션은 없다."""
publish: bool = False
class Res_StartBuild(Res_WebPacketProtocol):
job_id: Optional[uuid.UUID] = None
status: Optional[JobStatus] = None
created: bool = True
class SiteVersionData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True)
site_version_id: uuid.UUID
version: int
build_status: BuildStatus
unique_content_count: int = 0
build_error: Optional[str] = None
built_at: Optional[datetime] = None
created_at: Optional[datetime] = None
class SiteData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True)
site_id: uuid.UUID
place_id: uuid.UUID
status: SiteStatus
domain: Optional[str] = None
# 사장님이 고른 템플릿. 화면이 발행 전에 "지금 어느 템플릿으로 나가는지"를 보여줄 근거다.
template_id: Optional[str] = None
# 저장된 디자인(색·서체·섹션). ★ 반드시 응답으로 내려줘야 한다 —
# 에디터가 다시 열렸을 때 저장된 값을 읽을 곳이 없으면, 저장은 됐는데 화면은 기본값으로 돌아간다.
# 저장할 때와 같은 모양 그대로 돌려준다(서버가 해석하지 않으므로 변형할 이유도 없다).
theme: Optional[dict[str, Any]] = None
current_version_id: Optional[uuid.UUID] = None
published_at: Optional[datetime] = None
# 발행 썸네일(Azure Blob 공개 URL). 대표 사진을 옮긴 것이고, 만들지 못했으면 없다.
thumbnail_url: Optional[str] = None
class PublishLogData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True)
publish_log_id: uuid.UUID
action: PublishAction
result: PublishResult
reject_reason: Optional[PublishRejectReason] = None
detail: Optional[Any] = None
created_at: Optional[datetime] = None
class Req_SiteTemplate(SiteProtocol):
"""템플릿 선택 저장.
★ 서버는 값을 검증하지 않는다. 템플릿 목록은 프론트(배리에이션 레지스트리)가 소유하므로
여기서 화이트리스트를 두면 템플릿을 하나 늘릴 때마다 백엔드를 같이 고쳐야 한다.
잘못된 키가 들어와도 발행 잡이 업종 기본으로 떨어뜨린다 — 화면이 깨지지 않는다."""
template_id: str = ""
class Req_SiteTheme(SiteProtocol):
"""디자인(색·서체·섹션) 저장. 에디터 좌측 패널과 [디자인] 탭이 만든 결과 그대로 온다.
★ Req_SiteTemplate 과 같은 철학이다 — 서버는 값을 해석하지도 검증하지도 않는다.
섹션 목록도, 배리에이션 키도, 색 토큰 이름도 프론트(배리에이션 레지스트리)가 소유한다.
여기에 화이트리스트를 두면 프론트에 섹션이나 배리에이션이 하나 늘 때마다 백엔드를 같이 고쳐야 하고,
그 사이 사장님이 고른 값은 조용히 버려진다. 모르는 값이 들어와도 발행 잡이 업종 기본으로
떨어뜨리므로 화면은 깨지지 않는다.
★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
{"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}}
sections 는 {id, name, enabled, locked, variantId?, body?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
(별도 order 필드가 없다). variantId 와 직접 입력 본문 body 는 값이 있을 때만 키가 붙는다.
pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 —
서버는 배달부지 심판이 아니다.
★ colorPaletteId 는 **에디터 복원 전용**이다. 사장님이 고른 색 프리셋 id 이고,
발행 렌더러는 이걸 안 쓰고 해석된 colors 만 쓴다. DB 에는 저장하고 응답으로도 그대로 돌려주지만,
발행 payload 의 theme 에는 싣지 않는다 — 발행 계약(SitePayload.SiteTheme)에 없는 필드다.
★ templateId 는 이 body 에 없다. sites.template_id 컬럼과 POST /template 이 계속 담당한다 —
두 곳에 두면 어느 쪽이 진짜인지 갈린다.
★ 딱 하나 막는 것은 크기다. 해석하지 않는 값을 그대로 보관한다는 건 곧 무엇이든 들어올 수 있다는
뜻이라, 상한이 없으면 jsonb 컬럼 하나가 DB 와 스냅샷을 통째로 부풀린다.
상한(services/site_service._THEME_MAX_BYTES)은 서비스가 직렬화 크기로 잰다 —
필드 개수로 재면 값 하나가 긴 경우를 못 막는다.
"""
theme: dict[str, Any] = {}
class RenderStatusData(WebPacketProtocol):
"""정적 페이지가 실제로 구워졌는지. 프리렌더가 남긴 보고서를 그대로 옮긴다.
★ 발행 기록(DB)과 실제 페이지(파일)는 다른 곳에 산다. 이게 없으면 프리렌더가 깨져도
DB 는 "발행됨"이라 말하고 사장님은 [사이트 열기] 를 눌러야 404 로 알게 된다."""
# PENDING(아직) · STALE(낡음) · OK · FAILED
state: str = "PENDING"
rendered_at: Optional[str] = None
rendered_version: Optional[int] = None
error: Optional[str] = None
class Res_Site(Res_WebPacketProtocol):
site: Optional[SiteData] = None
current_version: Optional[SiteVersionData] = None
# ★ 노출값이 바뀐 뒤 다시 빌드하지 않았으면 True — 개별 재빌드 대상이라는 표시.
needs_rebuild: bool = False
# ★ 빌드(DB)와 렌더(정적 파일)는 다른 단계다. 빌드가 됐다고 페이지가 있는 게 아니다.
render: RenderStatusData = RenderStatusData()
class Res_SiteVersions(Res_WebPacketProtocol):
versions: list[SiteVersionData] = []
class Res_PublishLogs(Res_WebPacketProtocol):
logs: list[PublishLogData] = []
class AuditCheckData(WebPacketProtocol):
id: str
group: str
label: str
points: int
earned: int
status: str
detail: str
recommendation: Optional[str] = None
class Res_SeoAudit(Res_WebPacketProtocol):
seo_score: int = 0
aeo_score: int = 0
overall_score: int = 0
checks: list[AuditCheckData] = []
summary: dict[str, Any] = {}
visibility: dict[str, int] = {}
disclaimer: str = ""
class Req_SiteSlug(SiteProtocol):
"""사이트 주소(네임스페이스) 예약.
★ 서버가 상호명으로 자동 확정하지 않는다 — 사장님이 고른다.
주소는 AI 검색이 색인하는 영구 식별자라, 한 번 정해지면 되돌리는 비용이 사장님 몫이 된다."""
slug: str = ""
class Res_SlugCheck(Res_WebPacketProtocol):
"""주소 사용 가능 확인. UI 가 타이핑 중에 호출한다."""
available: bool = False
# 불가 사유(services/site_slug 의 REASON_*): INVALID_LENGTH / INVALID_FORMAT / RESERVED / TAKEN.
reason: Optional[str] = None
# 이름 자체는 멀쩡한데 못 쓰는 경우(예약어·중복)에만 대안을 하나 준다.
suggestion: Optional[str] = None
class Res_SiteSlug(Res_WebPacketProtocol):
"""주소 저장 결과. 거부됐으면 왜/대안을 check 와 같은 코드로 돌려준다."""
site: Optional[SiteData] = None
reason: Optional[str] = None
suggestion: Optional[str] = None
class Req_SiteStatus(SiteProtocol):
"""발행 상태 전이. ★ 해지는 삭제가 아니라 상태 전이다 —
색인된 페이지를 갑자기 404 로 만들면 그 자리를 다시 OTA 가 가져간다."""
action: PublishAction = PublishAction.SUSPEND
class ShowcaseItem(WebPacketProtocol):
"""랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.**
★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다.
place_id·company_id·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과
발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다."""
name: str
category: PlaceCategory
# "강원특별자치도 양양군" 수준. 주소를 못 읽으면 없다.
region: Optional[str] = None
# 발행 주소. 랜딩과 발행본이 한 오리진이라 루트 상대경로로 준다(`/s/<slug>`).
url: str
# 없으면 화면이 글자 카드로 떨어진다(썸네일은 발행의 부수 효과라 실패할 수 있다).
thumbnail_url: Optional[str] = None
class Res_Showcase(Res_WebPacketProtocol):
items: list[ShowcaseItem] = []