o2o-site-AEO/solution/backend/router/v1/site/site.py
Mina Choi f2087aad5e feat(solution): 발행 워커·버전 관리와 예약·미리보기 정리
상시 프리렌더와 중복 예약 안내를 없애고, 검수된 발행 버전을 보존한다. 미리보기는 실제 렌더 완료까지 스피너를 표시한다.

사이트 81건, 발행·롤백·서치콘솔 45건, 프로세스 수명 3건 통과. 빌더·사이트 빌드 및 compose 설정 검증 통과.
2026-09-15 16:12:16 +09:00

222 lines
10 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_Rollback,
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.post(
path="/version/rollback",
response_model=Res_StartBuild,
summary="예전 버전으로 되돌리기(비동기)",
description="target_version 을 다시 공개 주소로 내보낸다. 재수집·재생성을 하지 않는다 — "
"그 버전이 발행될 때 박제된 snapshot 을 그대로 다시 굽는다. 렌더러가 그 사이 "
"바뀌었을 수 있어 게이트는 다시 통과해야 한다. 실패하면 지금 공개된 버전은 그대로다.",
)
async def rollback_version(
place_id: UUID, req: Req_Rollback, service: SiteService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.start_rollback(user_info, str(place_id), req))
@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))