o2o-site-AEO/backend/router/v1/site/protocol.py
Mina Choi 6784e59ca5 최초 커밋 — 기존 코드 전체 + 문서 체계 신설
git 저장소가 없어 히스토리·협업 기반이 아예 없던 상태를 연다.
함께 문서를 재편했다. 그동안 문서가 있어도 "이 제품이 뭘 푸는가"와
"어떻게 도는가"를 담은 문서가 없어서, 목표 문장이 backend/frontend
README 두 곳에 복붙돼 있었다 — 상위 문서가 없어 아래로 샌 것이다.

신설
  README.md               레포 진입점 + 문서 지도 + 문서 규칙 4가지
  AGENTS.md               에이전트·신규 합류자용 함정 목록과 규약
                          (CLAUDE.md 는 여기로 걸린 심볼릭 링크)
  docs/PRODUCT.md         제품 정의 — 문제·사용자·원칙·**non-goals**·성공 기준
  docs/ARCHITECTURE.md    payload 경계·발행 파이프라인·서빙 결정·앱 분리 설계

이동
  backend/docs/DECISIONS.md → docs/DECISIONS.md
    백엔드만의 결정이 아니다. 게다가 코드 주석 ~25곳이 이미
    `docs/DECISIONS.md` 로 적고 있어 레포 루트 기준으로는 그게 맞다.

갱신
  docs/DEPLOY.md          서빙 결정 반영 — nginx 정적 서빙이 지금 경로(3절),
                          Azure 는 나중에 켤 때(4절)로 분리
  docs/ARCHITECTURE.md    사이트 = 한 장(2026-08-31) 구조 반영
  docs/COLLECTION_SEO_AEO_FLOW.md
                          robots.txt·sitemap.xml 은 오리진 루트에만 굽는다는 점 명시
  frontend/site/scripts/prerender.ts
                          헤더 주석의 렌더 보고서 경로가 실제(422줄)와 달라 수정

.gitignore
  ★ CLAUDE.md 를 더 이상 무시하지 않는다. 에이전트 지침은 팀과 모든
    에이전트가 공유하는 규약이라 커밋해야 한다 — 무시하면 클론한 사람이
    "배포 후 republish_all.py 필수" 같은 함정을 전달받지 못한다.
    개인용 오버라이드는 ~/.claude/CLAUDE.md 에 둔다.
2026-08-31 13:57:59 +09:00

195 lines
7.9 KiB
Python

import uuid
from datetime import datetime
from typing import Any, Optional
from pydantic import ConfigDict
from common.enums import BuildStatus, JobStatus, 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
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?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
(별도 order 필드가 없다). variantId 는 고른 게 있을 때만 키가 붙는다.
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