o2o-site-AEO/solution/backend/common/enums.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

393 lines
17 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() # 최고관리자 외 접근 / 다른 회사·최고관리자 대상 변경 시도
# 사업장(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 # GEMINI_API_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 가 아닌 잡을 재큐하려 함
# 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 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 # 음식점
TOUR_ACTIVITY = 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 공용 코드값.
값이 어디서 왔는지 — 모든 사실은 출처를 갖는다."""
OWNER = 1 # 사장님이 직접 입력·업로드
API = 2 # 공식 API (카카오 로컬 · TourAPI · Open-Meteo · Perplexity)
CRAWL = 3 # 크롤링
LLM = 4 # LLM 생성 — ★ 사실이 아니라 문장에만 쓴다
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_links.channel 코드값. Perplexity 가 발견하는 채널 종류."""
YANOLJA = 1 # 야놀자
GOODCHOICE = 2 # 여기어때
NAVER_PLACE = 3 # 네이버 플레이스
INSTAGRAM = 4
OFFICIAL_SITE = 5 # 사장님 자체 홈페이지
BLOG = 6
ETC = 99
class MediaStatus(CodeEnum):
"""media.status 코드값. 비전 결과 신뢰도가 낮으면 자동 반영하지 않고 PENDING_REVIEW 로 둔다."""
PENDING_REVIEW = 1 # 사람 확인 큐 — Vision 분석 전이거나 신뢰도가 낮다
APPROVED = 2 # 사람이 확인함(또는 Vision 신뢰도가 충분히 높음)
REJECTED = 3
# ★ Vision 결과를 자동 반영해도 되는 신뢰도 하한. 이 아래는 사람 확인 큐(PENDING_REVIEW)로 남긴다.
# "신뢰도 낮은 항목은 자동 반영하지 말고 사람 확인 큐로 보낸다" 를 한 곳에서만 판단한다.
VISION_AUTO_APPROVE_CONFIDENCE = 0.7
class LocalContentType(CodeEnum):
"""local_contents.content_type 코드값. 행정구역 코드 단위로 캐싱되는 지역 정보 종류."""
WEATHER = 1 # 날씨 (Open-Meteo)
FESTIVAL = 2 # 축제 (TourAPI 행사정보, 주 1회)
ATTRACTION = 3 # 관광지 (TourAPI 지역기반, 월 1회)
RESTAURANT = 4 # 주변 맛집 (카카오 카테고리 검색)
class LocalSource(CodeEnum):
"""local_contents.source 코드값. 어느 외부 API 에서 왔는지."""
OPEN_METEO = 1 # 날씨. API 키 불필요
TOUR_API = 2 # 한국관광공사. ★ 자체 areaCode 체계 — 카카오 행정구역 코드와 다르다
KAKAO_LOCAL = 3
OFFICIAL_WEB = 4 # 지자체·행사 공식 홈페이지에서 운영자가 검수해 등록
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
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 검색 노출 점검
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}