"""사이트 주소(네임스페이스) 규칙 — 확인(check)과 저장(POST)이 함께 쓰는 단 하나의 정의.""" import re from typing import Optional # 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_LENGTH = "INVALID_LENGTH" # 3~50자를 벗어남 REASON_FORMAT = "INVALID_FORMAT" # 한글·대문자·언더스코어·연속 하이픈·양끝 하이픈 REASON_RESERVED = "RESERVED" # 서비스가 선점한 이름 REASON_TAKEN = "TAKEN" # 다른 사업장이 이미 쓰는 주소 REASON_LOCKED = "LOCKED" # 이미 발행됨 — 색인된 주소는 바꾸지 않는다 # 예약어. 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", # 손으로 만든 목업이 쓰는 주소(solution/site/out/s, mockup/README). "stay", "stay2", "stay3", "stay4", "stay5", "stay6", }) def validate_slug(slug: Optional[str]) -> Optional[str]: """형식·예약어만 본다(중복은 DB 를 봐야 하므로 서비스가 판단한다).""" value = (slug or "").strip() if len(value) < SLUG_MIN_LEN or len(value) > SLUG_MAX_LEN: return REASON_LENGTH # 하이픈 연속은 정규식으로 막지 않는다(가운데 문자 집합이라 통과한다) — 여기서 따로 거른다. 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` … 을 붙인 대안 후보.""" value = (slug or "").strip() result: list[str] = [] for n in range(2, 2 + max(count, 0)): suffix = f"-{n}" # 50자 상한을 넘기지 않도록 앞을 자른다. base = value[: SLUG_MAX_LEN - len(suffix)].rstrip("-") candidate = f"{base}{suffix}" if validate_slug(candidate) is None: result.append(candidate) return result