o2o-site-AEO/solution/backend
Mina Choi 820a2e9e35 feat(site): 오늘의 날씨 문구 로딩과 조건별 순환 추가
목업에만 있던 날씨 문구 계약을 제품 payload와 렌더러에 연결한다. 군산 전용 장소를 다른 사업장에 복사하지 않도록 공통 안내를 별도 JSON으로 관리한다.

날씨 변환 단위 테스트 3건, 렌더링 테스트 3건 및 TypeScript·ESLint 통과. 전체 발행 테스트는 깨끗한 renderer 재빌드 후 별도 확인.
2026-09-15 16:23:53 +09:00
..
common feat(solution): 발행 워커·버전 관리와 예약·미리보기 정리 2026-09-15 16:12:16 +09:00
config [feat] solution/backend,site: 발행 사이트 제목·keywords 메타에 SiteOntology 키워드 — 이 가게 자료로 거른 것만 2026-09-14 14:38:24 +09:00
crud feat(solution): 발행 워커·버전 관리와 예약·미리보기 정리 2026-09-15 16:12:16 +09:00
router feat(solution): 발행 워커·버전 관리와 예약·미리보기 정리 2026-09-15 16:12:16 +09:00
scheduler [feat] solution/backend: 서치콘솔 자동 제출·색인 상태 추적 추가 2026-09-15 14:50:30 +09:00
scripts [feat] solution/backend: 서치콘솔 자동 제출·색인 상태 추적 추가 2026-09-15 14:50:30 +09:00
services feat(site): 오늘의 날씨 문구 로딩과 조건별 순환 추가 2026-09-15 16:23:53 +09:00
tests feat(site): 오늘의 날씨 문구 로딩과 조건별 순환 추가 2026-09-15 16:23:53 +09:00
worker feat(solution): 발행 워커·버전 관리와 예약·미리보기 정리 2026-09-15 16:12:16 +09:00
.env.example 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다 2026-08-31 15:12:09 +09:00
conftest.py feat(solution): 발행 워커·버전 관리와 예약·미리보기 정리 2026-09-15 16:12:16 +09:00
Dockerfile feat(backend,site): NOL 수집 및 숙박 안내 구조화와 지역 콘텐츠 연동 2026-09-14 19:40:04 +09:00
Dockerfile.worker feat(solution): 발행 워커·버전 관리와 예약·미리보기 정리 2026-09-15 16:12:16 +09:00
pytest.ini 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다 2026-08-31 15:12:09 +09:00
README.md 업종 4번째를 관광체험 → 피부과·성형외과 로 바꾸고, 로그인 관문을 에디터 진입으로 되돌린다 2026-09-02 15:42:34 +09:00
requirements.txt [feat] solution/backend: 서치콘솔 자동 제출·색인 상태 추적 추가 2026-09-15 14:50:30 +09:00
web_main.py 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다 2026-08-31 15:12:09 +09:00
worker_main.py [fix] solution/backend: 옛 테이블 이름 잔재로 빌더가 통째로 안 돌던 것 2026-09-09 17:08:51 +09:00

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), 전체 실행은 한 번에 하나만.

절대 규칙 (도메인 코드를 얹을 때)

  1. 미검증 fact 는 응답에 포함하지 않는다. VERIFIED/CORRECTED 만 노출. 특히 체크인·취사·반려동물·취소 규정.
  2. 고유 콘텐츠가 1건도 없으면 발행 API 가 거부한다.
  3. 구조화 데이터(JSON-LD) 값 = 화면에 보이는 값. 불일치 시 빌드 실패.
  4. 생성 사이트는 정적 빌드. DB 는 빌드 시점에만 읽고 방문자와 만나지 않는다.
  5. 개별 재빌드 단위로 설계. 전체 재빌드만 되면 사이트 1,000개에서 못 쓴다.
  6. 관리자에서 수정한 값은 잠긴다. 자동 갱신이 사장님 수정본을 덮어쓰지 않는다.
  7. LLM 은 사실을 만들지 않는다. 문장만 쓴다.
  8. 실시간 혼잡도·대기시간 API 를 만들지 않는다. 출처가 없다.
  9. 외부 API 실패 시 직전 값을 유지한다. 빈 값을 내보내지 않고 내부 알림만 발생시킨다.