o2o-site-AEO/solution/backend/README.md
Mina Choi 9b4fe4030b [feat] deploy,backend,site: 킹서버 배포 + 설정을 최상위 .env 하나로 통합
킹서버(o2oadmin@172.30.1.36)에 처음 올리면서, 서버에 올려야만 드러나는 결함 넷을 잡았다.
전부 "화면은 뜨는데 안 되는" 종류라 로컬에서는 끝까지 보이지 않는다.

- CORS 허용 오리진(client_url)만 env override 가 없었다. 도커가 굽던 config.local.toml 은
  플레이스홀더라 허용 목록이 localhost:3000~3005 뿐이고, 배포 주소에서는 모든 API 호출이
  프리플라이트에서 죽었다. 서버 로그에는 400 만 남아 원인이 CORS 라는 게 안 보인다
- .env 경로가 세 단계라 solution/.env(없는 파일)를 보고 있었다. 백엔드를 solution/ 아래로
  옮길 때 안 고쳐진 자리. toml 이 값을 들고 있어 로컬에서 드러나지 않았다
- admin 의 "빌더 열기" 가 VITE_SOLUTION_URL 미주입으로 localhost:3000 을 가리켰다
- PUBLIC_API_BASE_URL 은 브라우저가 부르는 주소인데 기본값이 localhost 라 서버에서 즉시 틀린다

설정 — toml 층 제거, pydantic-settings 로 전환 (FastAPI 공식 방식)
- config_loader.py · config.{local,test}.toml.example 삭제, 기본값은 config_models 로
- BaseSettings + env_file. `_apply_*_env_override` 4개 제거 — 키를 손으로 나열하는 구조라
  하나 빠뜨리면 조용히 틀렸고, 실제로 client_url 이 빠져 있었다
- 환경변수 이름은 validation_alias 로 못 박음. 필드명만 두면 `port` 가 흔한 `PORT` 를 먹는다
- 테스트 DB 분리(web4ai_test_db)는 config.test.toml 이 하던 몫이라 APP_ENV 기본값으로 이관
- lru_cache 로 .env 재읽기 방지. 새 코드는 Depends(get_*) 주입 가능
- 호출부 21개 파일 무변경 — server_configs 가 같은 이름을 계속 내보낸다

배포 — 킹서버는 :80 을 호스트 nginx 가 물고 있고 사내망에 열린 건 30xxx 뿐이다
- 컴포즈 포트를 전부 .env 변수로 추출(기본값은 기존 값 그대로, 로컬 무영향)
- 컨테이너 이름을 폴더 구조에 맞춤 — solution-backend·solution-worker·solution-frontend·
  solution-site·admin-backend·admin-frontend. api·web·nginx 는 어느 폴더 코드인지
  이름만으로 알 수 없었고, 백엔드 셋이 이미지 한 벌을 나눠 써서 특히 헷갈렸다
- worker 에 container_name 을 붙여 `-1` 접미사 제거(동시성은 WORKER_CONCURRENCY 가 맡는다)
- 어드민 앱·API 는 compose 프로필 뒤로 — 지금 안 쓴다. 켤 때 --profile admin
- deploy.sh: 서비스 하나를 지정해도 백엔드 형제를 함께 교체한다. 이미지 한 벌을 나눠 써서
  하나만 바꾸면 옛 코드로 도는 컨테이너가 남는데 `ps` 로는 셋 다 살아 있다
- log.sh: 1=전체, 2번부터 개별. compose v2.20 이 커스텀 --format 을 파싱하지 못해 상태가
  전부 "미기동" 으로 보이던 것도 --services --filter 로 교정
- docs/SERVERS.md 신설(접속·경로·포트·DB·sudo 없음), docs/DEVLOG.md 신설

정리
- 개발 전용 자동 로그인 제거 — 편의 하나에 검색 경로의 비동기 대기가 딸려 있었고,
  평문 비밀번호를 .env 에 두라고 권하는 모양새였다
- API 이름을 디렉토리에 맞춤: 사장님/내부 → 솔루션 API · 어드민 API (21곳)
- .env.example 을 읽는 폴더 기준 구역으로 재편 (solution/backend · solution/frontend ·
  solution/site · compose)
- AGENTS.md 에 negosium 브랜치·커밋 규약 명시

검증(킹서버 실측) — 컨테이너 4개 새 이름으로 기동, 솔루션 API·사장님 앱 200,
발행 사이트 404(발행물 없음, 정상), CORS 허용/차단 각 확인, toml 없이 부팅,
APP_ENV=test 시 web4ai_test_db·실키 미주입 확인.
2026-09-01 10:04:36 +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 · 포트 | 레포 최상위 `.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 실패 시 직전 값을 유지한다.** 빈 값을 내보내지 않고 내부 알림만 발생시킨다.