최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.
backend/ frontend/{admin,site,shared} → solution/{backend,front,site,shared} + admin/
## 왜
내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.
그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
(앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).
## admin 에 백엔드를 두지 않았다
내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.
## admin 의 `@` 는 solution/front/src 를 가리킨다
내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.
admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.
## 그 밖
- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
(conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.
검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
195 lines
7.9 KiB
Python
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
|