# o2o-web4ai Backend 상호명 하나로 소상공인 홈페이지를 만들어 주는 서비스의 API 서버. 목표는 예쁜 사이트가 아니라 **AI 검색이 이 가게를 공식 홈페이지 기준으로 설명하게 만드는 것**. `o2o-negosium/negodata/backend` 보일러플레이트를 이식했다 — 레이어 구조·설정 로딩·DB 세션·프로토콜 규약은 원본과 동일하다. ## 현재 상태 **수집 → 생성 → 빌드 → 발행 파이프라인이 API 로 다 열렸다.** 프론트(`solution/frontend`)가 이 API 를 붙여 쓴다. ★ **코드는 한 벌인데 진입점이 둘이다** — 솔루션 API `web_main.py`(:9800, 엔드포인트별 권한)와 어드민 API `admin/backend/main.py`(:9801, 앱 전체 `role >= DEVELOPER`). 같은 router 객체를 다시 마운트하고 앱 단위로 권한만 덧건다. 왜 경로 접두어가 아니라 포트인지는 [../../docs/ARCHITECTURE.md 4절](../../docs/ARCHITECTURE.md). | 모듈 | 상태 | 역할 | |---|---|---| | `places` | 완료 | 사업장 등록·조회, 동일 업소 검증, 하위 단위, 채널 URL 확정 | | `facts` | 완료 | fact CRUD, 검증 상태 전이, 업종 스키마 조회 | | `faqs` | 완료 | FAQ 목록·승인(검증 상태 전이)·직접 추가 — ★ 승인된 것만 FAQPage JSON-LD 로 나간다 | | `collector` | 완료 | 수집 파이프라인. 어댑터 `tour_api` · `naver_place` · `static_html` · `mock` — `COLLECT_ADAPTERS` 로 개별 on/off | | `generator` | 완료 | Gemini 호출 — 사진 분류(vision), 소개문·FAQ 작성(copy) | | `local` | 완료 | 지역 정보 (날씨·축제·관광지·맛집) + 행정구역 코드 단위 캐싱 | | `sites` | 완료 | 사이트 상태, 정적 빌드, 발행 검수 게이트, 발행 상태 전이 | | `media` | 완료 | 사진·alt 조회. 수집·비전이 채우고 빌더가 `useListMedia` 로 읽는다 | | `reports` | 미착수 | 노출 리포트, 유입 통계 | > OpenAPI 스펙은 `python scripts/export_openapi.py` 로 서버 없이 뽑을 수 있다 > (프론트의 orval 이 이 파일을 읽는다). ### 업종 스키마 업종마다 필드가 완전히 다르므로(숙박=체크인시간, 카페=브레이크타임) `facts` 는 key-value 로 두고, **어떤 key 가 존재하는가**는 업종별 JSON 스키마가 정의한다. ``` common/category_schema/resources/ ├── lodging.json 숙박 필드 30 (critical 14) ├── cafe.json 카페 필드 23 (critical 12) ├── restaurant.json 음식점 필드 24 (critical 15) └── clinic.json 피부과·성형외과 필드 24 (critical 17) ``` **업종 추가 = JSON 파일 1개 + `PlaceCategory` 코드 1줄.** 로직은 건드리지 않는다. 필드 속성 — `critical` 과 `allow_llm` 이 절대규칙과 직결된다. | 속성 | 뜻 | |---|---| | `scope` | `place`(사업장 단위) / `unit`(객실·메뉴·프로그램 단위) | | `required` | 발행 검수 게이트의 필수 항목. 빠지면 `PUBLISH_REQUIRED_FACT_MISSING` | | `critical` | ★ 틀리면 헛걸음·예약 클레임이 나는 항목. 미검증 상태로 절대 노출하지 않는다 | | `allow_llm` | LLM 이 값을 만들어도 되는가. **기본 False** — True 인 것은 소개문처럼 문장 자체가 산출물인 필드뿐 | ```python from common.category_schema import get_schema, is_valid_key from common.enums import PlaceCategory schema = get_schema(PlaceCategory.LODGING) schema.required_keys("place") # ['check_in_time', 'check_out_time', 'cancel_policy', ...] schema.critical_keys() # 미검증이면 노출 금지인 key 목록 is_valid_key(PlaceCategory.CAFE, "check_in_time") # False — 업종에 없는 key 는 거부 ``` ### fact 검증 상태 ``` UNVERIFIED ──┬─> PENDING_OWNER ──┬─> VERIFIED ──┬─> CORRECTED ──> REJECTED │ │ │ │ ▲ ├──────────────────>┤ ├───────┘ └── (사장님만 다시 고침) │ │ │ └─> REJECTED / EXPIRED ────────────┴──> UNVERIFIED (재수집으로만 부활) ``` - **노출 가능**: `VERIFIED` · `CORRECTED` (`PUBLISHABLE_FACT_STATUSES`) - **잠김**: `CORRECTED` (`LOCKED_FACT_STATUSES`) — 자동 갱신이 사장님 수정본을 덮어쓰지 않는다 - 허용 전이는 `FACT_STATUS_TRANSITIONS` 한 곳에만 있다. 없는 전이는 `FACT_INVALID_TRANSITION` 으로 거부 - **활성 fact 는 (사업장, 단위, key) 당 1건** — DB 부분 유니크 인덱스로 강제. `REJECTED`·`EXPIRED` 는 이력이라 유니크에서 빠진다 미결 사항(크롤링 법적 검토 · 이미지 재게시 권리 · 관리자 수정 범위 · 해지 정책)은 [../../docs/DECISIONS.md](../../docs/DECISIONS.md). ## 디렉토리 구조 ``` o2o-web4ai/ ├── .env.example # 외부 API 키·DB 접속 주입 템플릿 (cp .env.example .env) ├── postgres-init/ │ └── init-data/init.sql # 스키마 DDL 한 벌 (재실행 안전. 누적 ALTER 파일은 없다) ├── admin/backend/ # 어드민 API 진입점(:9801). 도메인 코드는 아래를 그대로 import 한다 └── solution/backend/ ├── web_main.py # 솔루션 API 진입점 :9800 (uvicorn) ├── worker_main.py # 잡 러너 + 스케줄러 ├── config/ # 환경설정 (APP_ENV 별 toml 로드) ├── conftest.py, tests/ # pytest (test DB 자동 create/drop) — 아래 '테스트' ├── common/ │ ├── enums.py # ErrorType / 코드값 enum / EXCEPTION_* / fact 상태 전이표 │ ├── category_schema/ # ★ 업종별 fact 스키마 (resources/*.json + 로더) │ ├── models/gmodel.py # 프로토콜 베이스 (WebPacketProtocol 등) │ └── database/ │ ├── db_session_manager.py# ★ DB Read/Write + 람다 실행 핵심 │ └── model/models.py # ORM 모델 (17 테이블) ├── crud/ # 도메인별 DB 접근 (I*CRUD 인터페이스 + 구현) ├── services/ # 비즈니스 로직 ├── scheduler/ # 배치 크론 (현재 등록된 잡 없음) └── router/ ├── router.py # FastAPI app (CORS 등) └── v1/ # 도메인별 라우터 └── validator/dependencies.py # ★ JWT 발급·검증, 해시(bcrypt), RemoveNoneResponse, RequireOwner/RequireDeveloper ``` ## 핵심 패턴 - **MVC**: router(컨트롤러) → service(로직) → crud(쿼리). crud 는 인터페이스/구현 분리. - **Depends 주입**: service 가 `IUserCRUD = Depends(UserCRUD)`, router 가 `Depends(AuthService)`, 인증은 `Depends(IsValidAccessToken)`. - **DB Read/Write 분리**: `DBType` × `DBWRType` 조합마다 별도 async 엔진(조회=Read, 변경=Write). - **람다 DB 실행**: service 는 세션을 직접 열지 않고 람다를 매니저에 넘긴다. 세션/트랜잭션은 매니저가 책임. ```python err, user = await DB_SESSION_MNG.execute_lambda( # 단일 조회 users.DBType(), DBWRType.DB_READ.value, lambda s: crud.get_user_by_login_id(s, id)) err = await DB_SESSION_MNG.execute_lambda_run( # 여러 변경 = 1 트랜잭션 [users.DBType()], [lambda s: crud.update_last_accessed(s, uid)]) ``` - **비동기**: 전 계층 async/await, SQLAlchemy async + asyncpg, task 단위 scoped session. - **Protocol 규약**: 모든 패킷 `WebPacketProtocol` 상속, `Req_*`/`Res_*`(응답엔 `result: ErrorInfo`), 라우터별 `protocol.py`. - **ResponseNone**: 응답의 `None` 필드 재귀 제거(`RemoveNoneResponse`). - **bcrypt 비차단**: `GetHashedPW`/`VerifyPW` 를 `asyncio.to_thread` 로 오프로드(이벤트 루프 비차단). ## API 도메인 (`/v1/*`) 전체 스펙은 실행 후 **http://localhost:9800/docs** (Swagger). | prefix | 요약 | |---|---| | `/healthz` | 헬스체크 — 서버 기동 시각(UTC) 반환 | | `/v1/auth` | 로그인 · access 토큰 재발급 · 내 정보(`me`) | | `/v1/place` | 사업장 등록·조회 · **동일 업소 검증** · 하위 단위(객실·메뉴·프로그램) · 채널 URL 등록/**확정** · **수집 시작** | | `/v1/place/{id}/fact` | 업종 스키마 조회 · fact 기록 · **검증 상태 전이** | | `/v1/place/{id}/faq` | FAQ 목록 · **승인·정정 전이** · 사장님 직접 추가 | | `/v1/place/{id}/media` | 사진·alt 조회 (수집·비전이 채운 것) | | `/v1/local` | 지역 콘텐츠 목록 · 축제 동기화 · 노출/종료 전이 | | `/v1/job` | 잡 상태 폴링 · 큐 운영 스냅샷 · DEAD 잡 재큐 | | `/v1/place/{id}/site` | 사이트 상태(재빌드 필요 여부) · **사이트 주소 확인/예약** · **정적 빌드/발행** · 버전 목록 · 발행 기록 · 발행 상태 전이 | 수집처럼 몇 분 걸리는 작업은 **잡을 적재하고 즉시 응답**한다. ``` POST /v1/place/{id}/collect → { job_id, status: PENDING } GET /v1/job/{job_id} → { status: RUNNING → DONE, result } ← 폴링 ``` ★ **발행 엔드포인트는 따로 없다.** 발행 검수 게이트가 BUILD 잡 안에 있어서 (`services/build_service` → `services/publish_gate`) 게이트를 우회하는 경로 자체를 만들지 않았다. 그래서 발행은 `POST /v1/place/{id}/site/build {publish: true}` 하나고, **잡이 DONE 이어도 발행됐다는 뜻이 아니다** — 게이트가 막으면 잡은 정상 종료하고 `result.gate.passed` 가 false, `build_status` 가 FAILED 로 온다. 호출측이 그걸 읽어야 한다. ### 사이트 주소(네임스페이스) 주소는 **사장님이 고른다.** 서버가 상호명으로 자동 확정하지 않는다 — 한 번 정하면 AI 검색이 색인하는 영구 식별자라 되돌리는 비용이 사장님 몫이 된다. ``` GET /v1/place/{id}/site/slug/check?slug=doflo → { available, reason?, suggestion? } POST /v1/place/{id}/site/slug {slug} → sites.domain 에 저장(사이트 행 없으면 생성) ``` - 규칙(정규식·예약어)은 [services/site_slug.py](services/site_slug.py) **한 곳**에 있고 확인과 저장이 같이 쓴다. 소문자 영문·숫자·하이픈 3~50자만 — 한글 주소는 퍼센트 인코딩(`/s/%EC%8A%A4…`)이라 사람이 불러줄 수 없다. - 저장 직전에 서버가 같은 규칙으로 다시 본다. **클라이언트 검증을 믿지 않는다.** - 중복은 `uq_sites_domain` 이 최종 판정한다. 지금 자기가 쓰는 주소면 중복이 아니다. - ★ **이미 발행된 사이트의 주소는 바꾸지 않는다**(`SITE_SLUG_LOCKED`). 색인된 주소가 바뀌면 AI 검색이 잡아 둔 페이지가 404 가 되고 그 자리를 다시 OTA 가 가져간다 — '해지는 상태 전이지 삭제가 아니다' 와 같은 이유다. ### DB 스키마 (17 테이블) | schema | 테이블 | |---|---| | `company` | `companies` `users` | | `place` | `places` `place_aliases` `place_links` `units` `media` | | `fact` | `facts` `faqs` | | `local` | `local_contents` `routes` `nearby_links` | | `site` | `sites` `site_versions` `publish_logs` `ai_check_results` | | `job` | `jobs` — 원자적 claim(`FOR UPDATE SKIP LOCKED`) + lease + dedupe + dead-letter | 핵심 게이트 3개가 컬럼으로 박혀 있다. | 게이트 | 컬럼 | 뜻 | |---|---|---| | 동일 업소 검증 | `places.verified_at` | NULL 이면 수집·발행 진입 금지 | | 채널 URL 확정 | `place_links.confirmed_at` | NULL 이면 크롤링 대상 아님 | | 사실 노출 | `facts.status` | `VERIFIED`·`CORRECTED` 만 사이트로 나간다 | > 인증 헤더: `Authorization: Bearer `. ## 설정 | 무엇 | 어디 | 커밋 | |---|---|---| | DB 접속 · JWT · 포트 | 레포 최상위 `.env` | ✗ (ignore) | | 외부 API 키 | 레포 최상위 `.env` | ✗ | | 템플릿 | `.env.example` | ✓ | 우선순위: **실제 환경변수(docker-compose) > `.env` > `config.{APP_ENV}.toml`** `APP_ENV=test` 이면 `.env` 를 읽지 않는다 — 실키가 새어 들어가면 테스트가 실제 외부 API 를 때린다. ## 실행 DB 준비(최초 1회) — 로컬 PostgreSQL 에 스키마를 적용한다. ```bash psql -h 127.0.0.1 -p 5432 -U postgres -f ../../postgres-init/init-data/init.sql # 호스트에 psql 이 없으면 도커 컨테이너의 psql 로 태운다: # docker exec -i -e PGPASSWORD=password negosium-db \ # psql -h 127.0.0.1 -U postgres -d postgres -v ON_ERROR_STOP=1 < ../../postgres-init/init-data/init.sql ``` 서버 실행. ```bash cd ../.. # 레포 루트 cp .env.example .env # 최초 1회, 외부 API 키 채우기 cd solution/backend cp .env.example .env # 레포 최상위. 최초 1회, DB·JWT 채우기 python3 -m venv .venv && source .venv/bin/activate pip install -r requirements.txt python web_main.py # APP_ENV 기본 local → http://localhost:9800/docs ``` 환경: `config.{local,test}.toml` (`APP_ENV` 로 선택). `SCHEDULER_ENABLED=1` 인 프로세스에서만 배치 스케줄러가 뜬다(다중 워커 중복 실행 방지). ## 테스트 **테스트는 호스트(venv)에서 돌린다** — DB(PostgreSQL)만 `127.0.0.1:5432` 에 떠 있으면 된다. test DB(`web4ai_test_db`)는 알아서 만들어졌다 지워지므로 **수동 세팅이 필요 없다.** ```bash cd solution/backend source .venv/bin/activate pip install pytest pytest-asyncio # 테스트 도구(requirements 에 없음) python -m pytest # 전체 python -m pytest -v # 테스트별 PASS/FAIL python -m pytest tests/test_auth.py # 파일 하나만 ``` venv 를 활성화하지 않으면 `.venv/bin/python -m pytest` 로 직접 지정한다. 정상이면 마지막 줄에 `NN passed`. 동작 방식 (전부 [conftest.py](conftest.py) 가 자동 처리): - `APP_ENV` 를 `test` 로 자동 설정 → DB 이름 기본값이 **`web4ai_test_db`** 로 갈린다(dev DB 와 완전 분리, `config_models.py`). - **세션 시작 시 test DB 를 새로 만들고(CREATE), 끝나면 내린다(DROP).** 매번 현재 모델로 새로 빌드돼 스키마가 낡을 일이 없다. - 테이블은 `create_all` 로 자동 생성, 매 테스트 전 `TRUNCATE` 로 비워 격리. - 안전가드: 이름에 `test` 없는 DB 는 만들지도 지우지도 않는다(실 DB 보호). > ⚠ **전체 실행(`python -m pytest`)을 동시에 두 개 돌리지 마라.** 세션마다 같은 test DB 를 > CREATE/DROP 하므로 서로의 DB 를 지워 엉뚱한 실패가 난다. 병렬로 작업 중이면 > 각자 파일 단위로 돌리고(`pytest tests/test_kakao.py`), 전체 실행은 한 번에 하나만. ## 절대 규칙 (도메인 코드를 얹을 때) 1. **미검증 fact 는 응답에 포함하지 않는다.** `VERIFIED`/`CORRECTED` 만 노출. 특히 체크인·취사·반려동물·취소 규정. 2. **고유 콘텐츠가 1건도 없으면 발행 API 가 거부한다.** 3. **구조화 데이터(JSON-LD) 값 = 화면에 보이는 값.** 불일치 시 빌드 실패. 4. **생성 사이트는 정적 빌드.** DB 는 빌드 시점에만 읽고 방문자와 만나지 않는다. 5. **개별 재빌드 단위로 설계.** 전체 재빌드만 되면 사이트 1,000개에서 못 쓴다. 6. **관리자에서 수정한 값은 잠긴다.** 자동 갱신이 사장님 수정본을 덮어쓰지 않는다. 7. **LLM 은 사실을 만들지 않는다.** 문장만 쓴다. 8. **실시간 혼잡도·대기시간 API 를 만들지 않는다.** 출처가 없다. 9. **외부 API 실패 시 직전 값을 유지한다.** 빈 값을 내보내지 않고 내부 알림만 발생시킨다.