from enum import Enum, auto from fastapi import HTTPException class CodeEnum(Enum): """OpenAPI 스키마에 x-enum-varnames(멤버 이름)을 실어 orval 이 이름 있는 enum 을 생성하게 하는 베이스.""" @classmethod def __get_pydantic_json_schema__(cls, core_schema, handler): json_schema = handler(core_schema) json_schema = handler.resolve_ref_schema(json_schema) json_schema["x-enum-varnames"] = [m.name for m in cls] return json_schema class ErrorType(Enum): """서버 전역 결과 코드.""" SUCCESS = 0 FAIL = 1 # DB / Redis 에러 DB_RUN_FAILED = 10 DB_ALREADY_SAME_KEY = auto() DB_INVALID_KEY = auto() DB_EMPTY_DATA = auto() DB_INVALID_TYPE = auto() # 요청/직렬화 에러 JSON_PARSE_ERROR = 100 INVALID_REQUEST_DATA = auto() INTERNAL_EXCEPTION = auto() # http 에러 코드와 겹치지 않게 설정 - router 전용 예외 발생 옵션 HTTP_FORBIDDEN = 403 HTTP_INVALID_CLIENT_REQUEST = 419 HTTP_TO_MANY_REQUEST = 429 HTTP_INVALID_CLIENT_ACCESS = 433 HTTP_ACCESS_TOKEN_EXPIRED = 434 HTTP_REFRESH_TOKEN_EXPIRED = 435 HTTP_INVALID_TOKEN_ACCESS = 436 # 계정 관련 에러 ACCOUNT_INVALID_INFO = 1100 ACCOUNT_ALREADY_EXIST = auto() ACCOUNT_BLOCKED_USER = auto() ACCOUNT_NOT_FOUND = auto() ACCOUNT_FORBIDDEN = auto() # 최고관리자 외 접근 / 다른 회사·최고관리자 대상 변경 시도 ACCOUNT_PROVIDER_CONFLICT = auto() # 이미 다른 로그인 수단으로 가입된 이메일 — 자동 연결하지 않는다(DECISIONS 1절) OAUTH_NOT_CONFIGURED = auto() # GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다 OAUTH_INVALID_TOKEN = auto() # 구글 ID 토큰 서명·수신자·만료 검증 실패 ACCOUNT_SESSION_REVOKED = auto() # 사업장(places) 관련 에러 PLACE_NOT_FOUND = 1200 PLACE_ALREADY_EXIST = auto() # 같은 회사 안에 같은 카카오 장소 ID — 중복 등록 PLACE_NOT_VERIFIED = auto() # 동일 업소 검증 전 — 수집·발행 진입 금지 PLACE_VERIFY_NO_CANDIDATE = auto() # 카카오 로컬에서 후보를 못 찾음 PLACE_VERIFY_AMBIGUOUS = auto() # 동명 업소 다수 — 사람이 골라야 함 PLACE_INVALID_CATEGORY = auto() # 지원하지 않는 업종 코드 UNIT_NOT_FOUND = auto() LINK_NOT_FOUND = auto() LINK_NOT_CONFIRMED = auto() # 확정 안 된 URL — 크롤링 대상 아님 MEDIA_NOT_FOUND = auto() # fact 관련 에러 FACT_NOT_FOUND = 1300 FACT_INVALID_KEY = auto() # 업종 스키마에 없는 key FACT_INVALID_TRANSITION = auto() # 허용되지 않은 검증 상태 전이 FACT_LOCKED = auto() # CORRECTED(사장님 수정본) — 자동 갱신이 덮어쓸 수 없다 FACT_SOURCE_REQUIRED = auto() # source_type 이 owner 가 아닌데 source_url 이 없음 FAQ_NOT_FOUND = auto() FAQ_UNGROUNDED = auto() # 확보된 fact 로 뒷받침되지 않는 문장 — 반려 # 수집(collector) 관련 에러 COLLECT_ADAPTER_NOT_FOUND = 1400 # 해당 URL 을 처리할 어댑터 없음 COLLECT_ADAPTER_DISABLED = auto() # 법무 검토 전이라 미등록된 어댑터(HeadlessAdapter 등) COLLECT_FETCH_FAILED = auto() COLLECT_ALREADY_RUNNING = auto() # 생성(generator) 관련 에러 GENERATOR_NOT_CONFIGURED = 1500 # 활성 LLM 공급자의 키 미설정(llm/provider.missing_key) GENERATOR_CALL_FAILED = auto() GENERATOR_INVALID_OUTPUT = auto() # 구조화 출력 파싱 실패 GENERATOR_LOW_CONFIDENCE = auto() # 신뢰도 낮음 — 자동 반영 금지, 사람 확인 큐로 # 지역 정보(local) 관련 에러 LOCAL_NOT_CONFIGURED = 1600 # KAKAO_REST_API_KEY / TOUR_API_KEY 미설정 LOCAL_REGION_UNKNOWN = auto() # 좌표 → 행정구역 코드 변환 실패 LOCAL_FETCH_FAILED = auto() # 실패해도 직전 값을 유지한다 — 빈 값을 내보내지 않는다 # 사이트(sites) 관련 에러 SITE_NOT_FOUND = 1700 SITE_VERSION_NOT_FOUND = auto() SITE_BUILD_FAILED = auto() PUBLISH_UNVERIFIED_FACT = auto() # 미검증 fact 포함 — 발행 거부 PUBLISH_NO_UNIQUE_CONTENT = auto() # 고유 콘텐츠 0건 — 발행 거부(스팸 판정 대상) PUBLISH_JSONLD_MISMATCH = auto() # 구조화 데이터 값 != 화면 값 — 빌드 실패 PUBLISH_REQUIRED_FACT_MISSING = auto() # 업종 스키마의 required 필드 누락 SITE_SLUG_LOCKED = auto() # 이미 발행된 사이트의 주소 변경 — 색인된 페이지가 404 가 된다 # 리포트(reports) 관련 에러 REPORT_NOT_FOUND = 1800 # 작업 큐(job) 관련 에러 JOB_NOT_FOUND = 1900 JOB_ALREADY_QUEUED = auto() # 같은 dedupe_key 의 활성 잡이 이미 있다 JOB_NOT_DEAD = auto() # DEAD 가 아닌 잡을 재큐하려 함 # 카카오톡 채널 신원 연결 관련 에러 KAKAO_LINK_DISABLED = 2000 # KAKAO_CHANNEL_PUBLIC_ID 미설정 — 연결 화면 자체를 열지 않는다 KAKAO_LINK_ALREADY = auto() # 이미 연결된 사장님이 다시 코드를 받으려 함 KAKAO_LINK_CODE_INVALID = auto() # 코드가 없거나 만료 — ★ 없는 코드와 남의 코드를 구분해 답하지 않는다 KAKAO_LINK_NOT_FOUND = auto() # 해제할 연결이 없음 KAKAO_LINK_TAKEN = auto() # 그 카카오 계정이 이미 다른 사장님에 묶여 있다 # ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다. EXCEPTION_FORBIDDEN = HTTPException(status_code=ErrorType.HTTP_FORBIDDEN.value, detail=ErrorType.HTTP_FORBIDDEN.name) EXCEPTION_INVALID_CLIENT_REQUEST = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_REQUEST.value, detail=ErrorType.HTTP_INVALID_CLIENT_REQUEST.name) EXCEPTION_TO_MANY_REQUEST = HTTPException(status_code=ErrorType.HTTP_TO_MANY_REQUEST.value, detail=ErrorType.HTTP_TO_MANY_REQUEST.name) EXCEPTION_INVALID_CLIENT_ACCESS = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_ACCESS.value, detail=ErrorType.HTTP_INVALID_CLIENT_ACCESS.name) EXCEPTION_ACCESS_TOKEN_EXPIRED = HTTPException(status_code=ErrorType.HTTP_ACCESS_TOKEN_EXPIRED.value, detail=ErrorType.HTTP_ACCESS_TOKEN_EXPIRED.name) EXCEPTION_REFRESH_TOKEN_EXPIRED = HTTPException(status_code=ErrorType.HTTP_REFRESH_TOKEN_EXPIRED.value, detail=ErrorType.HTTP_REFRESH_TOKEN_EXPIRED.name) EXCEPTION_HTTP_INVALID_TOKEN_ACCESS = HTTPException(status_code=ErrorType.HTTP_INVALID_TOKEN_ACCESS.value, detail=ErrorType.HTTP_INVALID_TOKEN_ACCESS.name) class DBType(Enum): """논리 DB 구분.""" MAIN = 1 class DBWRType(Enum): """Read / Write 접속 구분.""" DB_READ = 1 DB_WRITE = 2 # 도메인 코드값 class UserStatus(CodeEnum): """users.status 코드값.""" ACTIVE = 1 INACTIVE = 2 class UserRole(CodeEnum): """users.role 코드값.""" USER = 1 OWNER = 2 # 최고관리자: 자기 회사 계정 관리 + 회사 설정 DEVELOPER = 3 # 개발자(내부 운영): 최고관리자 권한 전부 + 고객사에 보이지 않음 class AuthProvider(CodeEnum): """users.provider 코드값.""" LOCAL = 1 # id/pw GOOGLE = 2 # 구글 ID 토큰 class CompanyStatus(CodeEnum): """companies.status 코드값.""" ACTIVE = 1 INACTIVE = 2 class PlaceCategory(CodeEnum): """places.category 코드값.""" LODGING = 1 # 숙박 CAFE = 2 # 카페 RESTAURANT = 3 # 음식점 CLINIC = 4 # 피부과·성형외과 class ExternalPlaceSource(CodeEnum): """places.external_source 코드값.""" KAKAO = 1 # dapi.kakao.com — 고유 place id O · 전화번호 O · 행정구역 코드 O NAVER = 2 # openapi.naver.com 지역검색 — 고유 id X · 전화번호 X · 5건 제한 class PlaceStatus(CodeEnum): """places.status 코드값.""" DRAFT = 1 # 등록만 됨 — 동일 업소 검증 전 COLLECTING = 2 # 수집 진행 중 REVIEW = 3 # 검수 대기(사람 확인 큐) PUBLISHED = 4 # 발행됨 SUSPENDED = 5 # 해지·중지 — 사이트 상태 전이로 처리 class SourceType(CodeEnum): """facts.source_type / media.source_type / place_aliases.source_type / place_faqs.generated_by 공용 코드값.""" OWNER = 1 # 사장님이 직접 입력·업로드 API = 2 # 공식 API (카카오 로컬 · TourAPI · Open-Meteo · Perplexity) CRAWL = 3 # 크롤링 LLM = 4 # LLM 생성 — ★ 사실이 아니라 문장에만 쓴다 TEMPLATE = 5 # FAQ 목표 수를 채운 공통 질문 + 문의 안내(services/faq_fill) — ★ FAQ 전용. class FactStatus(CodeEnum): """facts.status / faqs.status / routes.status 공용 검증 상태.""" UNVERIFIED = 1 # 수집됐으나 아무도 확인 안 함 PENDING_OWNER = 2 # 사장님 확인 대기 VERIFIED = 3 # 확인됨 — 노출 가능 CORRECTED = 4 # 사장님이 고침 — 노출 가능 + ★ 잠김(자동 갱신이 덮어쓸 수 없다) REJECTED = 5 # 틀린 값으로 판정 — 노출 안 함 EXPIRED = 6 # 유효기간 지남 — 노출 안 함, 재수집 대상 # 절대규칙 1: 이 두 상태만 사이트에 노출한다. PUBLISHABLE_FACT_STATUSES = {FactStatus.VERIFIED, FactStatus.CORRECTED} # 후보 — 재수집이 올려놓은 확인 대기 항목. CANDIDATE_FACT_STATUSES = {FactStatus.UNVERIFIED, FactStatus.PENDING_OWNER} # 절대규칙 6: 자동 수집(api/crawl/llm)이 덮어쓸 수 없는 상태. LOCKED_FACT_STATUSES = {FactStatus.CORRECTED} class FactWriteOutcome(CodeEnum): """fact 기록 결과.""" PUBLISHED_CREATED = 1 PUBLISHED_REPLACED = 2 REFRESHED = 3 # 재수집했는데 값이 그대로 — 검증 유지, 확인 시각만 갱신 CANDIDATE_CREATED = 4 CANDIDATE_UPDATED = 5 # 검증 상태 전이 허용표. FACT_STATUS_TRANSITIONS = { FactStatus.UNVERIFIED: {FactStatus.PENDING_OWNER, FactStatus.VERIFIED, FactStatus.REJECTED, FactStatus.EXPIRED}, FactStatus.PENDING_OWNER: {FactStatus.VERIFIED, FactStatus.CORRECTED, FactStatus.REJECTED, FactStatus.EXPIRED}, FactStatus.VERIFIED: {FactStatus.CORRECTED, FactStatus.REJECTED, FactStatus.EXPIRED}, FactStatus.CORRECTED: {FactStatus.CORRECTED, FactStatus.REJECTED}, # 사장님만 다시 고칠 수 있다 FactStatus.REJECTED: {FactStatus.UNVERIFIED}, # 재수집으로만 되살아난다 FactStatus.EXPIRED: {FactStatus.UNVERIFIED}, } class LinkChannel(CodeEnum): """place_channels.channel 코드값.""" YANOLJA = 1 # 야놀자 GOODCHOICE = 2 # 여기어때 NAVER_PLACE = 3 # 네이버 플레이스 INSTAGRAM = 4 OFFICIAL_SITE = 5 # 사장님 자체 홈페이지 BLOG = 6 # 플레이스와 가른 이유: 이건 **예약 화면 그 자체**다. NAVER_BOOKING = 7 # 네이버 예약(m.booking.naver.com) ETC = 99 class MediaStatus(CodeEnum): """media.status 코드값.""" PENDING_REVIEW = 1 # 사람 확인 큐 — Vision 분석 전이거나 신뢰도가 낮다 APPROVED = 2 # 사람이 확인함(또는 Vision 신뢰도가 충분히 높음) REJECTED = 3 class SongStatus(CodeEnum): """place_songs.status 코드값.""" GENERATING = 1 READY = 2 FAILED = 3 # 생성 실패. # Vision 결과를 자동 반영해도 되는 신뢰도 하한. VISION_AUTO_APPROVE_CONFIDENCE = 0.7 class LocalContentType(CodeEnum): """local_contents.content_type 코드값.""" WEATHER = 1 # 날씨 (Open-Meteo) — local_contents(지역 캐시) # ↓ 2~5 는 place_contents(업장 반경 캐시). FESTIVAL = 2 # 축제/공연/행사 (15) ATTRACTION = 3 # 관광지 (12) RESTAURANT = 4 # 음식점 (39) COURSE = 5 # 여행코스 (25) — 백엔드만. # 지역 이야기(가요·일력·인물·연표·엽서·퀴즈). STORY = 6 # 코드값 ↔ **타입명**. AREA_KIND = { LocalContentType.WEATHER.value: "weather", LocalContentType.FESTIVAL.value: "festival", LocalContentType.ATTRACTION.value: "attraction", LocalContentType.RESTAURANT.value: "restaurant", LocalContentType.COURSE.value: "course", } # 지역 이야기 일곱. STORY_KINDS = ("songs", "daily", "people", "chronicle", "reading", "postcard", "quiz") class LocalSource(CodeEnum): """local_contents.source 코드값.""" OPEN_METEO = 1 # 날씨. TOUR_API = 2 # 한국관광공사. KAKAO_LOCAL = 3 OFFICIAL_WEB = 4 # 지자체·행사 공식 홈페이지에서 운영자가 검수해 등록 # 지역 이야기 생성분. LLM = 5 NAVER_CRAWL = 6 # 네이버 플레이스 크롤링(주변 맛집 보강). class LocalContentStatus(CodeEnum): """운영 관리자가 검수하는 지역 콘텐츠 발행 상태.""" REVIEW = 1 PUBLISHED = 2 ENDED = 3 class TransportType(CodeEnum): """routes.transport 코드값.""" CAR = 1 PUBLIC = 2 WALK = 3 class SiteStatus(CodeEnum): """sites.status 코드값.""" DRAFT = 1 REVIEW = 2 # 검수 게이트 대기 PUBLISHED = 3 SUSPENDED = 4 # 해지 유예 — 페이지는 살아있음 UNPUBLISHED = 5 # 내림 class BuildStatus(CodeEnum): """site_versions.build_status 코드값.""" PENDING = 1 BUILDING = 2 BUILT = 3 FAILED = 4 class PublishAction(CodeEnum): """publish_logs.action 코드값.""" PUBLISH = 1 UNPUBLISH = 2 REBUILD = 3 SUSPEND = 4 RESUME = 5 ROLLBACK = 6 class PublishResult(CodeEnum): """publish_logs.result 코드값.""" SUCCESS = 1 REJECTED = 2 # 검수 게이트가 막음 — reject_reason 참고 FAILED = 3 # 빌드/배포 자체가 실패 class PublishRejectReason(CodeEnum): """publish_logs.reject_reason 코드값.""" UNVERIFIED_FACT = 1 # 미검증 fact 포함 NO_UNIQUE_CONTENT = 2 # 고유 콘텐츠 0건 JSONLD_MISMATCH = 3 # 구조화 데이터 값 != 화면 값 REQUIRED_FACT_MISSING = 4 # 업종 스키마 required 필드 누락 class AiEngine(CodeEnum): """ai_check_results.engine 코드값.""" CHATGPT = 1 PERPLEXITY = 2 GEMINI = 3 CLAUDE = 4 ETC = 99 # 작업 큐 (LPS 의 job 큐 구조를 이식 — PostgreSQL 을 큐로 쓴다) class JobType(CodeEnum): """jobs.job_type 코드값.""" COLLECT = 1 # 수집 파이프라인: Perplexity 채널 발견 → 카카오 검증 → 크롤링 VISION = 2 # 사진 분류 + alt 생성 (Gemini Vision, 20~50장 배치) COPY = 3 # 소개문·FAQ 작성 (Gemini 텍스트) — 확보된 fact 만 근거로 BUILD = 4 # 사이트 정적 빌드 — ★ 개별 재빌드 단위 LOCAL_SYNC = 5 # 지역 정보 갱신 — 행정구역 코드 단위(같은 지역 사이트 50개여도 1회) AI_CHECK = 6 # AI 검색 노출 점검 SONG = 7 # 이 숙소의 노래 한 곡 (가사 Gemini → 작곡 Suno). ROLLBACK = 8 SOCIAL_DRAFT = 9 # SNS 초안 작성(Gemini) — 확보된 fact 만 근거로 SOCIAL_POST = 10 # 승인된 SNS 초안을 실제 게시 class JobStatus(CodeEnum): """jobs.status 코드값.""" PENDING = 1 # 대기(claim 가능). RUNNING = 2 # 워커가 점유 중(lease_until 까지 소유). DONE = 3 # 완료 DEAD = 4 # dead-letter — max_attempts 소진(수동 개입/알림 대상) # claim 대상이 되는 활성 상태. ACTIVE_JOB_STATUSES = {JobStatus.PENDING, JobStatus.RUNNING} class PostTopicKind(CodeEnum): """place_posts.topic_kind — 어떤 갈래로 쓴 글인가.""" WEATHER = 1 # local.weather FESTIVAL = 2 # local.festivals SEASON = 3 # 절기·달 NEARBY = 4 # local.attractions / restaurants GUIDE = 5 # 이용 안내(검증된 fact 안에서) class PostStatus(CodeEnum): """place_posts.status — 글 하나의 일생.""" DRAFT = 1 REVIEWED = 2 # 우리가 검수해 내보내도 된다고 판단 SENT = 3 APPROVED = 4 PUBLISHED = 5 SKIPPED = 6 # 반려(우리) 또는 넘김(사장님) class ReviewStatus(CodeEnum): """place_reviews.status — 손님이 쓴 글의 일생.""" PENDING = 1 PUBLISHED = 2 # 검수 통과 — 다음 굽기에 실린다 REJECTED = 3 # 반려 class SocialProvider(CodeEnum): X = 1 THREADS = 2 class KakaoLinkStatus(str, Enum): """owner_kakao_links.status.""" PENDING = "PENDING" LINKED = "LINKED" REVOKED = "REVOKED" class SocialPostStatus(str, Enum): DRAFTING = "DRAFTING" DRAFT = "DRAFT" PENDING_APPROVAL = "PENDING_APPROVAL" APPROVED = "APPROVED" POSTING = "POSTING" POSTED = "POSTED" DECLINED = "DECLINED" EXPIRED = "EXPIRED" FAILED = "FAILED" UNKNOWN = "UNKNOWN" # 응답 유실·워커 중단: 자동 재시도는 중복 게시가 된다. class AlertStatus(CodeEnum): """alert_outbox.status 코드값.""" PENDING = 1 # 아직 안 보냄(다음 process_outbox 스윕에서 시도) SENT = 2 # 전송 성공 FAILED = 3 # 재시도 상한 소진 — 더 시도하지 않는다(사람이 outbox 를 봐야 한다)