o2o-site-AEO/solution/backend/services/site_slug.py
Mina Choi 9d25ed613e 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다
최상단을 프로젝트 단위로 평평하게 둔다 — 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
2026-08-31 15:12:09 +09:00

87 lines
5.2 KiB
Python

"""사이트 주소(네임스페이스) 규칙 — 확인(check)과 저장(POST)이 함께 쓰는 단 하나의 정의.
★ 주소는 한 번 정하면 AI 검색이 색인하는 영구 식별자다. 그래서
- 규칙은 이 파일 한 곳에만 둔다. 확인과 저장이 서로 다른 규칙을 쓰면
"쓸 수 있다고 해놓고 저장에서 튕기는" 최악의 화면이 나온다.
- 클라이언트 검증은 믿지 않는다. 저장 직전에 서버가 이 규칙으로 다시 본다.
★ ASCII 소문자·숫자·하이픈만 받는다.
한글 상호를 그대로 주소로 쓰면 경로는 퍼센트 인코딩(/s/%EC%8A%A4%ED%85%8C%EC%9D%B4),
서브도메인은 퓨니코드(xn--…)가 된다 — 사장님이 전화로 불러줄 수도, 명함에 적을 수도 없는 주소다.
(프론트 `shared/src/lib/slug.ts` 의 publishUrl 이 ASCII 슬러그면 서브도메인으로 낸다.)
"""
import re
from typing import Optional
# 3~50자. 처음과 끝은 영문 소문자·숫자여야 하고, 하이픈은 가운데에만 올 수 있다.
# (1 + 1~48 + 1 = 3~50자)
SLUG_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]{1,48}[a-z0-9])$")
SLUG_MIN_LEN = 3
SLUG_MAX_LEN = 50
# 불가 사유 코드. 응답의 reason 으로 그대로 나가고, 프론트가 문구를 고른다.
REASON_LENGTH = "INVALID_LENGTH" # 3~50자를 벗어남
REASON_FORMAT = "INVALID_FORMAT" # 한글·대문자·언더스코어·연속 하이픈·양끝 하이픈
REASON_RESERVED = "RESERVED" # 서비스가 선점한 이름
REASON_TAKEN = "TAKEN" # 다른 사업장이 이미 쓰는 주소
REASON_LOCKED = "LOCKED" # ★ 이미 발행됨 — 색인된 주소는 바꾸지 않는다
# 예약어. 두 부류가 섞여 있다.
# 1) 서비스가 실제로 쓰는 경로·서브도메인(/s, /healthz, api. …) — 내주면 사이트가 서로를 가린다.
# 2) 관용적으로 시스템을 뜻하는 이름(admin, root, www …) — 사장님 사이트가 이 주소를 갖고 있으면
# 방문자도 크롤러도 공식 홈페이지로 읽지 않는다.
RESERVED_SLUGS = frozenset({
# 서비스 경로 / 인프라 서브도메인
"s", "site", "sites", "api", "app", "admin", "www", "www2", "static", "assets", "cdn", "media",
"img", "images", "css", "js", "files", "download", "downloads", "public", "dev", "stage", "staging",
"test", "demo", "local", "localhost", "mail", "smtp", "imap", "pop", "ftp", "ns", "ns1", "ns2", "mx",
"webmail", "vpn", "proxy", "gateway", "internal", "console", "dashboard", "manage", "management",
# 서버가 실제로 서비스하는 엔드포인트
"health", "healthz", "healthcheck", "status", "metrics", "robots", "sitemap", "llms", "favicon",
"manifest", "feed", "rss", "opensearch", "well-known", "v1", "v2", "graphql", "openapi", "docs", "swagger",
# 계정 / 정책 페이지
"login", "logout", "signin", "signup", "register", "join", "auth", "oauth", "account", "accounts",
"user", "users", "me", "my", "owner", "root", "system", "support", "help", "contact", "about",
"terms", "privacy", "policy", "legal", "security", "billing", "payment", "pay", "order", "orders",
# 도메인 리소스 이름 — 관리 API 경로와 같아 헷갈린다
"place", "places", "fact", "facts", "faq", "faqs", "job", "jobs", "report", "reports", "unit", "units",
"search", "index", "home", "main", "new", "edit", "delete", "create", "update",
# 값이 비었을 때 프론트가 문자열로 흘려보내는 것들 — 주소로 들어오면 버그의 흔적이다
"null", "undefined", "none", "nan", "true", "false",
})
def validate_slug(slug: Optional[str]) -> Optional[str]:
"""형식·예약어만 본다(중복은 DB 를 봐야 하므로 서비스가 판단한다).
쓸 수 있으면 None, 아니면 REASON_* 를 돌려준다."""
value = (slug or "").strip()
if len(value) < SLUG_MIN_LEN or len(value) > SLUG_MAX_LEN:
return REASON_LENGTH
# 하이픈 연속은 정규식으로 막지 않는다(가운데 문자 집합이라 통과한다) — 여기서 따로 거른다.
# a--b 는 퓨니코드 접두(xn--)와 모양이 겹쳐서 서브도메인으로 냈을 때 오해를 산다.
if "--" in value:
return REASON_FORMAT
if not SLUG_PATTERN.fullmatch(value):
return REASON_FORMAT
if value in RESERVED_SLUGS:
return REASON_RESERVED
return None
def suggestions(slug: Optional[str], count: int = 9) -> list[str]:
"""`-2`, `-3` … 을 붙인 대안 후보. 실제로 쓸 수 있는지(중복)는 서비스가 DB 로 거른다.
★ 음차하거나 상호를 마음대로 줄이지 않는다 — 서버가 고른 주소는 사장님이 기억하지 못한다.
사장님이 적어낸 이름을 그대로 두고 뒤에 숫자만 붙인다."""
value = (slug or "").strip()
result: list[str] = []
for n in range(2, 2 + max(count, 0)):
suffix = f"-{n}"
# 50자 상한을 넘기지 않도록 앞을 자른다. 자른 끝이 하이픈이면 `a--2` 가 되므로 떼어낸다.
base = value[: SLUG_MAX_LEN - len(suffix)].rstrip("-")
candidate = f"{base}{suffix}"
if validate_slug(candidate) is None:
result.append(candidate)
return result