공급자를 openai 로 바꾼 뒤에도 호출측에 "GEMINI_API_KEY 미설정" 이 문자열로 박혀 있어서, **없는 것은 OPENAI_API_KEY 인데 화면은 Gemini 를 탓했다** (실측 2026-09-21: 로컬에서 소개문이 안 나와 Gemini 키를 한참 들여다봤다). 원인을 정확히 반대로 가리키는 종류다. - llm/provider: missing_key() 추가 — 활성 공급자에게 필요한 env 이름을 돌려준다 - copy_service·collect_service·song_service: 하드코딩 문구를 그 함수로 교체 - enums: GENERATOR_NOT_CONFIGURED 주석도 공급자 중립으로 관련 테스트 55 passed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
531 lines
25 KiB
Python
531 lines
25 KiB
Python
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):
|
|
"""서버 전역 결과 코드. Res_WebPacketProtocol.result 에 담겨 클라이언트로 전달된다.
|
|
HTTP status 와 겹치지 않도록 구간을 분리해서 관리한다.
|
|
|
|
도메인 코드는 모듈이 붙을 때 구간을 새로 열어 추가한다
|
|
(places 1200 / facts 1300 / collector 1400 / generator 1500 / local 1600 / sites 1700 / reports 1800 예약).
|
|
"""
|
|
|
|
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() # ★ refresh 토큰의 token_version 이 지금 DB 값과 다르다 — 그 뒤로 무효화됐다(비밀번호 변경 등)
|
|
|
|
# 사업장(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 구분. 모델마다 DBType() 으로 자신이 속한 DB 를 반환한다.
|
|
DB 가 늘어나면 여기에 추가하고 db_session_manager 의 맵에 등록만 하면 된다.
|
|
"""
|
|
|
|
MAIN = 1
|
|
|
|
|
|
class DBWRType(Enum):
|
|
"""Read / Write 접속 구분. 조회는 DB_READ, 변경은 DB_WRITE 엔진을 사용한다."""
|
|
|
|
DB_READ = 1
|
|
DB_WRITE = 2
|
|
|
|
|
|
# 도메인 코드값
|
|
class UserStatus(CodeEnum):
|
|
"""users.status 코드값."""
|
|
|
|
ACTIVE = 1
|
|
INACTIVE = 2
|
|
|
|
|
|
class UserRole(CodeEnum):
|
|
"""users.role 코드값.
|
|
1=일반, 2=최고관리자(고객사 최상위), 3=개발자(우리 내부 운영 계정).
|
|
개발자 계정은 고객사에 존재를 노출하지 않는다 — 회원 목록에서 빼고 총계에도 넣지 않는다."""
|
|
|
|
USER = 1
|
|
OWNER = 2 # 최고관리자: 자기 회사 계정 관리 + 회사 설정
|
|
DEVELOPER = 3 # 개발자(내부 운영): 최고관리자 권한 전부 + 고객사에 보이지 않음
|
|
|
|
|
|
class AuthProvider(CodeEnum):
|
|
"""users.provider 코드값. 이 계정이 무엇으로 신원을 증명하는가.
|
|
|
|
한 계정은 수단 하나다 — 같은 이메일이라도 id/pw 계정과 구글 계정을 자동으로 잇지 않는다.
|
|
이으려면 "먼저 가입한 쪽의 소유"를 증명받아야 하는데, 그 증명 없이 이메일만 보고 이으면
|
|
남이 먼저 만들어 둔 계정에 내 구글 로그인이 들어간다(계정 선점). 보류 사유는 DECISIONS.md 1절."""
|
|
|
|
LOCAL = 1 # id/pw
|
|
GOOGLE = 2 # 구글 ID 토큰
|
|
|
|
|
|
class CompanyStatus(CodeEnum):
|
|
"""companies.status 코드값."""
|
|
|
|
ACTIVE = 1
|
|
INACTIVE = 2
|
|
|
|
|
|
class PlaceCategory(CodeEnum):
|
|
"""places.category 코드값. 업종 — 스키마 파일(common/category_schema/resources/*.json)과 1:1.
|
|
업종 추가 = 여기에 코드 추가 + 스키마 파일 1개 추가."""
|
|
|
|
LODGING = 1 # 숙박
|
|
CAFE = 2 # 카페
|
|
RESTAURANT = 3 # 음식점
|
|
CLINIC = 4 # 피부과·성형외과
|
|
|
|
|
|
class ExternalPlaceSource(CodeEnum):
|
|
"""places.external_source 코드값. 동일 업소 검증에 쓴 외부 장소 DB.
|
|
|
|
카카오는 안정적인 고유 place id 를 준다 → 그걸로 중복 등록을 막는다.
|
|
네이버는 고유 id 가 없다(응답의 link 는 업체 홈페이지다) → 상호명+도로명주소로 막는다."""
|
|
|
|
KAKAO = 1 # dapi.kakao.com — 고유 place id O · 전화번호 O · 행정구역 코드 O
|
|
NAVER = 2 # openapi.naver.com 지역검색 — 고유 id X · 전화번호 X · 5건 제한
|
|
|
|
|
|
class PlaceStatus(CodeEnum):
|
|
"""places.status 코드값. 사업장 생애주기.
|
|
해지는 삭제가 아니라 SUSPENDED 로의 상태 전이다(색인된 페이지를 갑자기 404 로 만들지 않는다)."""
|
|
|
|
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 전용. fact 에는 못 쓴다
|
|
|
|
|
|
class FactStatus(CodeEnum):
|
|
"""facts.status / faqs.status / routes.status 공용 검증 상태.
|
|
★ VERIFIED 와 CORRECTED 만 사이트에 노출한다(PUBLISHABLE_FACT_STATUSES)."""
|
|
|
|
UNVERIFIED = 1 # 수집됐으나 아무도 확인 안 함
|
|
PENDING_OWNER = 2 # 사장님 확인 대기
|
|
VERIFIED = 3 # 확인됨 — 노출 가능
|
|
CORRECTED = 4 # 사장님이 고침 — 노출 가능 + ★ 잠김(자동 갱신이 덮어쓸 수 없다)
|
|
REJECTED = 5 # 틀린 값으로 판정 — 노출 안 함
|
|
EXPIRED = 6 # 유효기간 지남 — 노출 안 함, 재수집 대상
|
|
|
|
|
|
# ★ 절대규칙 1: 이 두 상태만 사이트에 노출한다. 발행 게이트가 이 집합으로 필터링한다.
|
|
PUBLISHABLE_FACT_STATUSES = {FactStatus.VERIFIED, FactStatus.CORRECTED}
|
|
|
|
# 후보 — 재수집이 올려놓은 확인 대기 항목. 노출값과 달리 (place, unit, key) 당 여러 건 공존한다.
|
|
CANDIDATE_FACT_STATUSES = {FactStatus.UNVERIFIED, FactStatus.PENDING_OWNER}
|
|
|
|
# ★ 절대규칙 6: 자동 수집(api/crawl/llm)이 덮어쓸 수 없는 상태. 사장님 수정본은 후보로만 도전받는다.
|
|
LOCKED_FACT_STATUSES = {FactStatus.CORRECTED}
|
|
|
|
|
|
class FactWriteOutcome(CodeEnum):
|
|
"""fact 기록 결과. 재수집(업데이트)이 무엇을 했는지 호출측이 알아야 한다 —
|
|
특히 사이트 재빌드가 필요한 경우(PUBLISHED_REPLACED)를 구분해야 한다."""
|
|
|
|
PUBLISHED_CREATED = 1 # 노출값이 없던 자리에 사람이 직접 넣어 바로 노출됐다
|
|
PUBLISHED_REPLACED = 2 # ★ 노출값이 교체됐다 — 사이트 재빌드 대상
|
|
REFRESHED = 3 # 재수집했는데 값이 그대로 — 검증 유지, 확인 시각만 갱신
|
|
CANDIDATE_CREATED = 4 # 노출값과 다른 값이 들어와 후보로 쌓였다(사람 확인 대기)
|
|
CANDIDATE_UPDATED = 5 # 같은 출처의 기존 후보를 새 수집값으로 갱신했다
|
|
|
|
# 검증 상태 전이 허용표. 여기에 없는 전이는 FACT_INVALID_TRANSITION 으로 거부한다.
|
|
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 코드값. Perplexity 가 발견하는 채널 종류."""
|
|
|
|
YANOLJA = 1 # 야놀자
|
|
GOODCHOICE = 2 # 여기어때
|
|
NAVER_PLACE = 3 # 네이버 플레이스
|
|
INSTAGRAM = 4
|
|
OFFICIAL_SITE = 5 # 사장님 자체 홈페이지
|
|
BLOG = 6
|
|
# ★ 플레이스와 가른 이유: 이건 **예약 화면 그 자체**다.
|
|
# 플레이스 홈은 예약 버튼을 한 번 더 눌러야 하고, 자동 발견이 검색 URL 을 물어온
|
|
# 경우에는 아예 검색 결과가 뜬다 — 발행본의 "예약" 버튼이 그리로 가면 손님은
|
|
# 예약을 포기한다. 주소는 지어내지 않는다: 플레이스 응답의 naverBookingUrl 그대로다.
|
|
NAVER_BOOKING = 7 # 네이버 예약(m.booking.naver.com)
|
|
ETC = 99
|
|
|
|
|
|
class MediaStatus(CodeEnum):
|
|
"""media.status 코드값. 비전 결과 신뢰도가 낮으면 자동 반영하지 않고 PENDING_REVIEW 로 둔다."""
|
|
|
|
PENDING_REVIEW = 1 # 사람 확인 큐 — Vision 분석 전이거나 신뢰도가 낮다
|
|
APPROVED = 2 # 사람이 확인함(또는 Vision 신뢰도가 충분히 높음)
|
|
REJECTED = 3
|
|
|
|
|
|
class SongStatus(CodeEnum):
|
|
"""place_songs.status 코드값.
|
|
|
|
★ fact·사진과 달리 검증 상태가 없다. 노래는 수집한 사실이 아니라 우리가 만든 창작물이라
|
|
"맞는가" 를 물을 대상이 아니다. 물을 것은 "만들어졌는가" 하나다.
|
|
★ 사이트에는 READY 만 나간다 — 생성 중인 곡을 실으면 재생 버튼이 없는 파일을 가리킨다."""
|
|
|
|
GENERATING = 1 # Suno 가 작곡 중(또는 파일을 아직 못 받았다)
|
|
READY = 2 # 파일까지 받아 뒀다 — 사이트에 나간다
|
|
FAILED = 3 # 생성 실패. 발행은 그대로 진행된다(노래만 없다)
|
|
|
|
|
|
# ★ Vision 결과를 자동 반영해도 되는 신뢰도 하한. 이 아래는 사람 확인 큐(PENDING_REVIEW)로 남긴다.
|
|
# "신뢰도 낮은 항목은 자동 반영하지 말고 사람 확인 큐로 보낸다" 를 한 곳에서만 판단한다.
|
|
VISION_AUTO_APPROVE_CONFIDENCE = 0.7
|
|
|
|
|
|
class LocalContentType(CodeEnum):
|
|
"""local_contents.content_type 코드값. 행정구역 코드 단위로 캐싱되는 지역 정보 종류."""
|
|
|
|
WEATHER = 1 # 날씨 (Open-Meteo) — local_contents(지역 캐시)
|
|
# ↓ 2~5 는 place_contents(업장 반경 캐시). TourAPI locationBasedList2 contentTypeId 와 짝: 15·12·39·25
|
|
FESTIVAL = 2 # 축제/공연/행사 (15)
|
|
ATTRACTION = 3 # 관광지 (12)
|
|
RESTAURANT = 4 # 음식점 (39)
|
|
COURSE = 5 # 여행코스 (25) — 백엔드만. 렌더러 자리는 아직 없다
|
|
# ★ 지역 이야기(가요·일력·인물·연표·엽서·퀴즈). 위 넷과 달리 **좌표가 아니라 행정구역**에 붙는다 —
|
|
# 군산 이야기는 군산 숙소가 같이 쓴다. 여섯을 한 코드로 두고 `area_contents.kind` 로 가르는 이유는,
|
|
# 종류마다 코드를 주면 종류가 늘 때마다 enum·상한표·읽는 쪽이 함께 늘기 때문이다.
|
|
STORY = 6
|
|
|
|
|
|
# 코드값 ↔ **타입명**. `area_contents.kind` 와 `site_sections.data.items[].kind` 가 같은 어휘를 쓴다 —
|
|
# 개인화 행(거리·숨김)이 어느 공용 실체를 가리키는지 이름만 보고 알 수 있어야 한다.
|
|
# ★ STORY 는 여기 없다. 그것들(songs·people·chronicle·reading·postcard·quiz)은 kind 가 곧 타입명이고,
|
|
# 코드값 하나(6)를 나눠 쓴다. 아래 표는 kind 가 비어 있던 장소류를 채우기 위한 것이다.
|
|
AREA_KIND = {
|
|
LocalContentType.WEATHER.value: "weather",
|
|
LocalContentType.FESTIVAL.value: "festival",
|
|
LocalContentType.ATTRACTION.value: "attraction",
|
|
LocalContentType.RESTAURANT.value: "restaurant",
|
|
LocalContentType.COURSE.value: "course",
|
|
}
|
|
|
|
# 지역 이야기 일곱. `services/prompts/story.py` 의 산출물 키와 같아야 한다.
|
|
# ★ 순서는 발행본 '지역 이야기' 탭 순서다(`site/sections/items/StorySection.tsx`).
|
|
STORY_KINDS = ("songs", "daily", "people", "chronicle", "reading", "postcard", "quiz")
|
|
|
|
|
|
class LocalSource(CodeEnum):
|
|
"""local_contents.source 코드값. 어느 외부 API 에서 왔는지."""
|
|
|
|
OPEN_METEO = 1 # 날씨. API 키 불필요
|
|
TOUR_API = 2 # 한국관광공사. ★ 자체 areaCode 체계 — 카카오 행정구역 코드와 다르다
|
|
KAKAO_LOCAL = 3
|
|
OFFICIAL_WEB = 4 # 지자체·행사 공식 홈페이지에서 운영자가 검수해 등록
|
|
# ★ 지역 이야기 생성분. 출처는 항목 안의 source.url 이고 이 값은 '누가 모았나'다 —
|
|
# 화면이 "AI 가 모았습니다"를 밝힐 근거이자, 나중에 통째로 다시 돌릴 때의 선택자다.
|
|
LLM = 5
|
|
NAVER_CRAWL = 6 # 네이버 플레이스 크롤링(주변 맛집 보강). docs/DECISIONS.md 1-1 예외 — 봇탐지 우회 없이 공개 응답만 읽는다
|
|
|
|
|
|
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 # 예전 버전으로 공개 주소를 되돌림 — services/rollback_service.py
|
|
|
|
|
|
class PublishResult(CodeEnum):
|
|
"""publish_logs.result 코드값."""
|
|
|
|
SUCCESS = 1
|
|
REJECTED = 2 # 검수 게이트가 막음 — reject_reason 참고
|
|
FAILED = 3 # 빌드/배포 자체가 실패
|
|
|
|
|
|
class PublishRejectReason(CodeEnum):
|
|
"""publish_logs.reject_reason 코드값. 검수 게이트가 발행을 막은 이유(절대규칙 1~3)."""
|
|
|
|
UNVERIFIED_FACT = 1 # 미검증 fact 포함
|
|
NO_UNIQUE_CONTENT = 2 # 고유 콘텐츠 0건
|
|
JSONLD_MISMATCH = 3 # 구조화 데이터 값 != 화면 값
|
|
REQUIRED_FACT_MISSING = 4 # 업종 스키마 required 필드 누락
|
|
|
|
|
|
class AiEngine(CodeEnum):
|
|
"""ai_check_results.engine 코드값. AI 검색이 우리 사이트를 근거로 답하는지 측정할 대상."""
|
|
|
|
CHATGPT = 1
|
|
PERPLEXITY = 2
|
|
GEMINI = 3
|
|
CLAUDE = 4
|
|
ETC = 99
|
|
|
|
|
|
# ============================================================
|
|
# 작업 큐 (LPS 의 job 큐 구조를 이식 — PostgreSQL 을 큐로 쓴다)
|
|
# ============================================================
|
|
class JobType(CodeEnum):
|
|
"""jobs.job_type 코드값. 수집·비전·빌드는 몇 분씩 걸려 동기 요청으로 처리할 수 없다.
|
|
|
|
무거운 잡(브라우저 필요)과 가벼운 잡(HTTP API 만)을 코드로 갈라 둔다 —
|
|
크롤링 법무 결론이 나면 무거운 잡만 별도 워커 이미지로 분리한다."""
|
|
|
|
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 코드값. 작업 큐 상태.
|
|
|
|
전이는 전부 조건부 원자 UPDATE(CAS)로만 한다. 실패는 재시도 가능하면
|
|
PENDING(run_after=백오프)으로 되돌리고, 소진되면 DEAD(dead-letter)."""
|
|
|
|
PENDING = 1 # 대기(claim 가능). run_after <= now() 일 때만 실제 claim 대상
|
|
RUNNING = 2 # 워커가 점유 중(lease_until 까지 소유). 만료 시 reaper 가 회수
|
|
DONE = 3 # 완료
|
|
DEAD = 4 # dead-letter — max_attempts 소진(수동 개입/알림 대상)
|
|
|
|
|
|
# claim 대상이 되는 활성 상태. dedupe 부분 유니크 인덱스의 조건과 같아야 한다.
|
|
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 # AI 가 만들었고 아직 아무도 안 봤다
|
|
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 행에만 산다. 연결이 끝나면 code_sha 를 비워 같은 코드가 두 번
|
|
먹지 않게 한다 — 일회성은 값이 아니라 `WHERE status='PENDING'` CAS 가 보장한다."""
|
|
|
|
PENDING = "PENDING" # 코드는 냈고 아직 카톡에서 입력되지 않았다
|
|
LINKED = "LINKED" # channel_user_key 가 붙었다
|
|
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 코드값. services/alert_service.py 가 이 상태로 재시도를 판단한다."""
|
|
|
|
PENDING = 1 # 아직 안 보냄(다음 process_outbox 스윕에서 시도)
|
|
SENT = 2 # 전송 성공
|
|
FAILED = 3 # 재시도 상한 소진 — 더 시도하지 않는다(사람이 outbox 를 봐야 한다)
|