o2o-site-AEO/solution/backend/README.md
Mina Choi 4871e50327 문서: 앱을 가른 뒤 낡아진 서술을 고치고, 개발과 무관해진 기록을 지운다
지운 것 — 앞으로의 개발에 쓸 데가 없다.
- solution/backend/demo_site.html: 어떤 스크립트도 만들지 않는 고아 산출물이고,
  손으로 쓴 HTML 이라 "백엔드는 HTML 을 만들지 않는다" 와도 어긋난다.
- solution/README.md: front/ · admin/.env · 사이트당 rooms/index.html·sitemap.xml 처럼
  지금은 전부 틀린 서술이었다. 살아 있는 두 가지(빌더 CSR vs 발행물 SSG 대비표,
  하이드레이션 블롭에 미검증 값이 샜던 실측)는 ARCHITECTURE 로 옮겼다.
- docs/API_USAGE.md 의 Claude 개발비 집계: 2026-08-27 스냅샷과 재집계 스크립트는
  일회성 지출 기록이라 제품 원가와 성격이 다르다. 문서를 외부 API 원가 하나로 좁혔다.

고친 것 — 코드를 따라가지 못하던 서술.
- 코드 경로가 solution/backend 로 옮겨진 뒤 `backend/...` 로 남아 있던 포인터 전부.
  가리키는 자리가 없는 경로는 문서가 아니라 함정이다.
- ARCHITECTURE: 트리의 front→frontend, 컨테이너 표에 api-admin(:9801)·admin(:3002) 추가.
- ★ ARCHITECTURE·AGENTS 의 "admin 전용 라우터가 0개" 는 사실이 아니었다.
  /v1/admin/local-content 가 admin 전용인데 :9800 에도 마운트돼 있다 —
  포트를 가른 논리에 아직 남은 구멍이라 그렇게 적었다.
- DECISIONS: 결론난 것을 미결로 두면 함정이 된다. 작업 큐(2026-08-27 결론),
  날씨 캐시 TTL 1시간, jobs 테이블, media 조회 API, 수집 체인을 결론으로 옮기고
  네이버 플레이스 대 TourAPI 실측(2026-08-31)을 1-1 에 이었다.
- API_USAGE: 어댑터가 다 붙고 TourAPI 키도 나왔다. "실호출 0건" 은 낡은 서술이었다.
- backend/README: 16→17 테이블(jobs), 없어진 alters/, MockAdapter 만이라는 서술,
  cd backend 경로, media·local 라우터 누락.
2026-08-31 16:58:09 +09:00

267 lines
16 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 를 붙여 쓴다.
★ **코드는 한 벌인데 진입점이 둘이다** — 사장님 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)
└── 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 한 벌 (재실행 안전. 누적 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 <access_token>`.
## 설정
| 무엇 | 어디 | 커밋 |
|---|---|---|
| DB 접속 · JWT · 포트 | `solution/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 solution/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 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` 로 자동 설정 → `config/config.test.toml`([예제](config/config.test.toml.example)) 의 **`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 실패 시 직전 값을 유지한다.** 빈 값을 내보내지 않고 내부 알림만 발생시킨다.