o2o-site-AEO/solution/backend/README.md
Mina Choi 5ef3e5a7de 업종 4번째를 관광체험 → 피부과·성형외과 로 바꾸고, 로그인 관문을 에디터 진입으로 되돌린다
## 업종 교체 (tour → clinic)

PlaceCategory 코드 4번의 의미를 바꾼다. 아직 배포 전이라 데이터 마이그레이션은 없다.

- category_schema: tour_activity.json → clinic.json. 체험 스키마(안전 유의사항·우천 시
  운영·준비물)를 진료 스키마(진료과목·의료진·상담료·보험 적용·야간/주말진료)로 바꿨다.
  unit 은 프로그램 → 시술이다(마취 방식·회복 기간·권장 횟수·시술 후 주의사항).
- 소개문 계열만 allow_llm 이다. 시술 효과·비용 같은 값은 LLM 이 못 쓴다 —
  이 레포의 "검증 전에는 발행 금지" 규칙이 의료 문구에서 특히 중요하다.
- jsonld: TouristAttraction → MedicalClinic. 프론트 AeoReadiness 의 같은 표도 맞췄다.
- 색 팔레트를 병원 톤(클린 블루·세이지·누드·모노)으로, 아이콘을 Compass → Stethoscope 로.
- mock_adapter 목데이터를 시술 기준으로 교체. 스키마에 없는 key 를 쓰면 수집이 죽는다.
- site_payload 의 기본 섹션표를 에디터(industryData)와 같게 맞췄다 —
  test_site_theme 이 이 둘을 대조한다.

## 로그인 관문 되돌리기 (b94daa9·d6a6c8e revert)

두 커밋이 /builder 를 통째로 RequireAuth 뒤로 옮겨 `/` 가 곧바로 로그인 화면이 됐다.
`/` 는 자기 화면 없이 /builder 로 넘기기만 하므로, 문 앞 가드는 곧 루트 가드다.
위저드를 열어 두고 에디터 진입에서 한 번 받는 969fb67 설계로 되돌린다.
d6a6c8e 가 스스로 "969fb67 과 정면으로 다른 설계"라고 적어 두었다.

## 그 밖

- test_site_theme 의 경로가 solution/front 로 남아 있었다(frontend 개명 누락).
- .dockerignore: 이 머신에 buildx 가 없어 레거시 빌더가 돌고, 그러면
  nginx/Dockerfile.dockerignore 가 무시된다. 루트 것 하나로 두 이미지를 다 커버한다.

검증: frontend·admin·site lint·build 0. 백엔드 534 passed / 4 failed —
그 4개(test_build_publish 3 · test_snapshot 1)는 이 변경 전부터 실패하던 것이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xa8ME5FQJy4VA8pPokTo1a
2026-09-02 15:42:34 +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)
└── 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 <access_token>`.
## 설정
| 무엇 | 어디 | 커밋 |
|---|---|---|
| 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 실패 시 직전 값을 유지한다.** 빈 값을 내보내지 않고 내부 알림만 발생시킨다.