목업에만 있던 날씨 문구 계약을 제품 payload와 렌더러에 연결한다. 군산 전용 장소를 다른 사업장에 복사하지 않도록 공통 안내를 별도 JSON으로 관리한다. 날씨 변환 단위 테스트 3건, 렌더링 테스트 3건 및 TypeScript·ESLint 통과. 전체 발행 테스트는 깨끗한 renderer 재빌드 후 별도 확인. |
||
|---|---|---|
| .. | ||
| common | ||
| config | ||
| crud | ||
| router | ||
| scheduler | ||
| scripts | ||
| services | ||
| tests | ||
| worker | ||
| .env.example | ||
| conftest.py | ||
| Dockerfile | ||
| Dockerfile.worker | ||
| pytest.ini | ||
| README.md | ||
| requirements.txt | ||
| web_main.py | ||
| worker_main.py | ||
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절.
| 모듈 | 상태 | 역할 |
|---|---|---|
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 인 것은 소개문처럼 문장 자체가 산출물인 필드뿐 |
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.
디렉토리 구조
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 는 세션을 직접 열지 않고 람다를 매니저에 넘긴다. 세션/트랜잭션은 매니저가 책임.
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 한 곳에 있고 확인과 저장이 같이 쓴다.
소문자 영문·숫자·하이픈 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 에 스키마를 적용한다.
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
서버 실행.
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)는 알아서 만들어졌다 지워지므로 수동 세팅이 필요 없다.
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 가 자동 처리):
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), 전체 실행은 한 번에 하나만.
절대 규칙 (도메인 코드를 얹을 때)
- 미검증 fact 는 응답에 포함하지 않는다.
VERIFIED/CORRECTED만 노출. 특히 체크인·취사·반려동물·취소 규정. - 고유 콘텐츠가 1건도 없으면 발행 API 가 거부한다.
- 구조화 데이터(JSON-LD) 값 = 화면에 보이는 값. 불일치 시 빌드 실패.
- 생성 사이트는 정적 빌드. DB 는 빌드 시점에만 읽고 방문자와 만나지 않는다.
- 개별 재빌드 단위로 설계. 전체 재빌드만 되면 사이트 1,000개에서 못 쓴다.
- 관리자에서 수정한 값은 잠긴다. 자동 갱신이 사장님 수정본을 덮어쓰지 않는다.
- LLM 은 사실을 만들지 않는다. 문장만 쓴다.
- 실시간 혼잡도·대기시간 API 를 만들지 않는다. 출처가 없다.
- 외부 API 실패 시 직전 값을 유지한다. 빈 값을 내보내지 않고 내부 알림만 발생시킨다.