작업트리에 커밋되지 않은 채 쌓여 있던 것과, 오늘 찾은 문제 셋을 함께 담는다. ## 1. 콘텐츠 생성 진행 상태 (작업트리에 있던 것) COPY 잡의 실제 단계를 DB에 기록하고 응답으로 내보낸다. 폴링 횟수로 진행률을 흉내 내던 것을 걷어냈다. 새로고침·재접속해도 jobId 로 이어서 본다. - services/copy_steps.py · services/job_progress.py · common/job_errors.py (신규) - postgres-init/migrations/0013_job_progress.sql + init.sql - 프론트: useGenerationJob · generationLabels (신규), Step5Generating·pollJob 배선, orval 모델 갱신(jobProgress · jobStep · jobStepStatus · jobStepReason) - docs/GENERATION_FLOW.md (신규) ## 2. 발행된 사이트만 색인한다 실측(2026-09-15): 디스크의 발행본 33곳 중 **15곳이 draft 인데 `index, follow`** 였고 사이트맵에도 올라가 있었다. 사장님이 발행 버튼을 누른 적 없는 사이트가 짓다 만 상태로 구글에 실려 있었다는 뜻이다. head.ts 가 robots 를 하드코딩하고 payload 의 `site.status` 를 보지 않았다. "색인을 막을 이유가 없다"는 주석은 굽는 것이 곧 발행이던 시절의 말인데, 지금은 빌더 미리보기만 눌러도 draft 로 구워진다. - seo/head.ts: PUBLISHED 일 때만 index, 아니면 `noindex, follow` - 사이트맵·`/s` 목록·llms.txt 에서도 함께 빠진다 — 그쪽은 구운 HTML 의 robots 를 읽어 거른다(seo/directory.ts readBakedNoindex). 규칙을 두 자리에 두지 않으려고 한 곳에 뒀다 ## 3. [새로 크롤링하고 사이트 생성하기] 를 뒤집지 않는다ba90a19의 중복 합치기가 **일부러 다시 만들려는 경우까지** 기존 사업장으로 끌고 갔다 — 새로 만들기를 눌렀는데 기존 에디터가 열린다(사장님 보고 2026-09-15). - Req_VerifyPlaceByUrl.reuse_existing (기본 True — 다른 호출자의 동작은 그대로) - place_service.verify_place_by_url: 끄면 이어붙이지 않는다. 다만 **비어 있는 중복 행은 계속 치운다** — 원래 막으려던 누적이 그것이고 빈 행은 잃을 것이 없다 - ensureServerPlace: 위저드는 새로 만들기 경로에서만 오므로 False 로 보낸다 ## 4. 발행본 파비콘 발행본에 파비콘 링크가 아예 없어 브라우저 탭에 기본 아이콘이 떴다. 파일은 오리진 루트의 공용 자산이라 사이트마다 복사하지 않고 루트 절대경로로 가리킨다. 검증: site vitest 84건 통과 · tsc(site·frontend) · eslint 통과. 백엔드 pytest 는 로컬 DB 비밀번호가 맞지 않아 돌리지 못했다(a5b8701과 같은 자리). 발행본 반영에는 전체 재굽기가 필요하다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
273 lines
11 KiB
Python
273 lines
11 KiB
Python
import uuid
|
|
from datetime import datetime
|
|
from typing import Any, Optional
|
|
|
|
from pydantic import ConfigDict
|
|
|
|
from common.enums import (
|
|
BuildStatus,
|
|
JobStatus,
|
|
PlaceCategory,
|
|
PlaceStatus,
|
|
PublishAction,
|
|
PublishRejectReason,
|
|
PublishResult,
|
|
SiteStatus,
|
|
)
|
|
from common.models.gmodel import Res_PageProtocol, 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 Req_Rollback(SiteProtocol):
|
|
"""예전 버전으로 공개 주소를 되돌린다.
|
|
|
|
★ 재굽기가 아니다 — 대상 버전이 디스크에 아직 있으면 심볼릭 링크만 돌린다. 지워졌으면
|
|
(보관 정책, prerender.ts pruneOldVersions) site_versions.snapshot 으로 다시 굽고 나서
|
|
돌린다. 어느 경우든 게이트를 다시 통과해야 한다(사장님이 이미 확인한 값이라 대개는
|
|
그대로 통과한다)."""
|
|
|
|
target_version: int
|
|
|
|
|
|
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 MySiteData(WebPacketProtocol):
|
|
"""내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site).
|
|
|
|
★ render 는 여기 없다 — 보고서 **파일**을 읽는 값이라 줄 수만큼 파일 IO 가 된다(단건이 소유).
|
|
★ site_id 아래가 전부 None 이면 아직 사이트가 없는 사업장이다."""
|
|
|
|
place_id: uuid.UUID
|
|
name: str
|
|
category: PlaceCategory
|
|
place_status: PlaceStatus
|
|
road_address: Optional[str] = None
|
|
created_at: Optional[datetime] = None
|
|
|
|
site_id: Optional[uuid.UUID] = None
|
|
status: Optional[SiteStatus] = None
|
|
domain: Optional[str] = None
|
|
template_id: Optional[str] = None
|
|
published_at: Optional[datetime] = None
|
|
# 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url).
|
|
# Azure 썸네일 저장소가 안 꺼져 있으면(로컬 개발) 빌더가 쓰는 대표 사진으로 대신 채운다
|
|
# (site_service._my_site_row) — 이때는 `?v=` 가 없다.
|
|
thumbnail_url: Optional[str] = None
|
|
# 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다.
|
|
needs_rebuild: bool = False
|
|
|
|
|
|
class Res_MySites(Res_PageProtocol):
|
|
sites: list[MySiteData] = []
|
|
|
|
|
|
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": "...", "look": {...}, "colorPaletteId": "...", "sections": [...]}}
|
|
sections 는 {id, name, enabled, locked, variantId?, body?, data?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
|
|
(별도 order 필드가 없다). variantId·본문 body·붙여넣기 JSON data 는 값이 있을 때만 키가 붙는다.
|
|
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 가 가져간다.
|
|
|
|
★ 기본값을 두지 않는다. SUSPEND 가 기본이던 동안에는 필드 이름을 틀리게 보내도
|
|
(`{"status": 5}` 처럼) 422 가 아니라 **발행 중지가 실행됐다** — 파괴적인 전이가
|
|
'아무것도 안 적었을 때' 의 자리에 있었다(실측 2026-09-15).
|
|
무엇을 할지는 부르는 쪽이 적는다."""
|
|
|
|
action: PublishAction
|
|
|
|
|
|
class ShowcaseItem(WebPacketProtocol):
|
|
"""랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.**
|
|
|
|
★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다.
|
|
place_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] = []
|