o2o-site-AEO/solution/backend
2026-09-11 16:25:59 +09:00
..
common Merge branch 'main' into feature/scheduler 2026-09-11 14:03:27 +09:00
config [feat] solution,postgres-init: 발행하면 이 숙소의 노래가 한 곡 생긴다 — 가사 Gemini · 작곡 Suno 2026-09-11 11:21:16 +09:00
crud Merge branch 'main' into feature/scheduler 2026-09-11 14:03:27 +09:00
router [feat] solution: 캔버스도 서버가 만든 일정을 본다 — 발행본과 같은 목록 2026-09-11 11:58:09 +09:00
scheduler 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다 2026-08-31 15:12:09 +09:00
scripts [refactor] postgres-init,solution: DB 구조 재편 — 스키마 해체 · 공용 콘텐츠 한 벌 · 마이그레이션 체계 2026-09-09 17:08:02 +09:00
services [feat] solution/backend: 일정 하루 시각을 체크인·체크아웃 흐름으로 강제한다 2026-09-11 16:25:59 +09:00
tests [feat] solution/backend: 일정 하루 시각을 체크인·체크아웃 흐름으로 강제한다 2026-09-11 16:25:59 +09:00
worker [feat] solution,postgres-init: 발행하면 이 숙소의 노래가 한 곡 생긴다 — 가사 Gemini · 작곡 Suno 2026-09-11 11:21:16 +09:00
.env.example 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다 2026-08-31 15:12:09 +09:00
conftest.py [fix] solution/backend: conftest 를 되돌린다 — 테스트가 안 돈 건 DB 비밀번호 탓이었다 2026-09-11 10:47:33 +09:00
Dockerfile [feat] deploy,backend,site: 킹서버 배포 + 설정을 최상위 .env 하나로 통합 2026-09-01 10:04:36 +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] deploy,backend,site: 킹서버 배포 + 설정을 최상위 .env 하나로 통합 2026-09-01 10:04:36 +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 · mockCOLLECT_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줄. 로직은 건드리지 않는다.

필드 속성 — criticalallow_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/VerifyPWasyncio.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_serviceservices/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_ENVtest 로 자동 설정 → 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 실패 시 직전 값을 유지한다. 빈 값을 내보내지 않고 내부 알림만 발생시킨다.