## 업종 교체 (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
267 lines
16 KiB
Markdown
267 lines
16 KiB
Markdown
# 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 실패 시 직전 값을 유지한다.** 빈 값을 내보내지 않고 내부 알림만 발생시킨다.
|