o2o-site-AEO/solution/backend/README.md
Mina Choi c85c577349 이름: solution/front → solution/frontend
`backend` 옆에 `front` 가 있을 이유가 없었다. negosium 의 negodata/front 를 그대로
베꼈고 그게 왜 front 인지는 따져보지 않았다 — 근거 없이 들여온 이름이라 바로잡는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 15:27:16 +09:00

258 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# o2o-web4ai Backend
상호명 하나로 소상공인 홈페이지를 만들어 주는 서비스의 API 서버.
목표는 예쁜 사이트가 아니라 **AI 검색이 이 가게를 공식 홈페이지 기준으로 설명하게 만드는 것**.
`o2o-negosium/negodata/backend` 보일러플레이트를 이식했다 — 레이어 구조·설정 로딩·DB 세션·프로토콜 규약은 원본과 동일하다.
## 현재 상태
**수집 → 생성 → 빌드 → 발행 파이프라인이 API 로 다 열렸다.** 프론트(`solution/frontend`)가 이 API 를 붙여 쓴다.
| 모듈 | 상태 | 역할 |
|---|---|---|
| `places` | 완료 | 사업장 등록·조회, 동일 업소 검증, 하위 단위, 채널 URL 확정 |
| `facts` | 완료 | fact CRUD, 검증 상태 전이, 업종 스키마 조회 |
| `faqs` | 완료 | FAQ 목록·승인(검증 상태 전이)·직접 추가 — ★ 승인된 것만 FAQPage JSON-LD 로 나간다 |
| `collector` | 완료 | 수집 파이프라인 (어댑터 패턴 — Phase 1 은 MockAdapter 만) |
| `generator` | 완료 | Gemini 호출 — 사진 분류(vision), 소개문·FAQ 작성(copy) |
| `local` | 스키마 완료 / API 미착수 | 지역 정보 (날씨·축제·관광지·맛집) + 행정구역 코드 단위 캐싱 |
| `sites` | 완료 | 사이트 상태, 정적 빌드, 발행 검수 게이트, 발행 상태 전이 |
| `media` | 스키마 완료 / 조회 API 미착수 | 사진·alt. 수집·비전이 채우지만 아직 읽을 창구가 없다 |
| `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)
└── tour_activity.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 전부 (재실행 안전)
│ └── alters/ # 컬럼 추가/타입 변경 누적 (YYYY-MM-DD-<주제>.sql)
└── backend/
├── web_main.py # 엔트리포인트 (uvicorn)
├── 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 모델 (16 테이블)
├── crud/ # 도메인별 DB 접근 (I*CRUD 인터페이스 + 구현)
├── services/ # 비즈니스 로직
├── scheduler/ # 배치 크론 (현재 등록된 잡 없음)
└── router/
├── router.py # FastAPI app (CORS 등)
└── v1/ # 도메인별 라우터
└── validator/dependencies.py # ★ JWT 발급·검증, 해시(bcrypt), RemoveNoneResponse, RequireOwner
```
## 핵심 패턴
- **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/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 스키마 (16 테이블)
| 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` |
핵심 게이트 3개가 컬럼으로 박혀 있다.
| 게이트 | 컬럼 | 뜻 |
|---|---|---|
| 동일 업소 검증 | `places.verified_at` | NULL 이면 수집·발행 진입 금지 |
| 채널 URL 확정 | `place_links.confirmed_at` | NULL 이면 크롤링 대상 아님 |
| 사실 노출 | `facts.status` | `VERIFIED`·`CORRECTED` 만 사이트로 나간다 |
> 인증 헤더: `Authorization: Bearer <access_token>`.
## 설정
| 무엇 | 어디 | 커밋 |
|---|---|---|
| DB 접속 · JWT · 포트 | `backend/config/config.local.toml` | ✗ (`*.toml` ignore) |
| 외부 API 키 | 레포 최상위 `.env` | ✗ |
| 템플릿 | `config/config.local.toml.example` · `.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 backend
cp config/config.local.toml.example config/config.local.toml # 최초 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 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` 로 자동 설정 → [config.test.toml](config/config.test.toml) 의 **`web4ai_test_db`** 사용(dev DB 와 완전 분리).
- **세션 시작 시 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 실패 시 직전 값을 유지한다.** 빈 값을 내보내지 않고 내부 알림만 발생시킨다.