o2o-site-AEO/solution/backend
Mina Choi 9f16c3224b [feat] solution/backend,frontend: 내 사이트 목록을 카드로 — 썸네일·주소·시각 · 발행마다 그림 갱신
목록 줄이 아이콘·상호·배지·주소 넷뿐이었다. 서버는 이미 road_address·created_at·
published_at 을 주는데 화면이 안 썼다. 실측(계정 test): 35줄 중 34줄이 발행 전이고
같은 상호 '버터브루' 가 4줄이라 어느 게 어느 건지 가릴 단서가 화면에 없었다.

리서치 — Wix My Sites 는 이름·URL·Premium·협업자만 두고 검색·그리드/리스트 전환·폴더가 있다.
Sites API 문서가 권하는 조합은 displayName·thumbnail·viewUrl·editUrl 이다.
아임웹 내사이트는 **실제 화면을 열어 봤다**(imweb.me 가이드): 줄 왼쪽에 큰 가로형 썸네일,
상호 아래 도메인, 그리고 도메인/SSL·PG 신청처럼 **안 끝난 설정**을 줄 안에 배지로 늘어놓는다.
공통 원칙은 목록이 ① 구분 ② 상태 ③ 여는 길 셋만 한다는 것 — 통계는 사이트 안 대시보드다.

- protocol·site_service: `MySiteData.thumbnail_url` 추가. 목록이 사이트 행을 이미 조인해
  읽고 있어서 쿼리는 그대로다
- site_thumbnail: 공개 주소에 `?v=<발행 버전>`. 블롭 이름은 고정이고 내용만 덮어쓰므로
  주소가 안 변하면 사진을 바꿔 재발행해도 **캐시에 남은 지난 그림**이 계속 보인다
  (CACHE_CONTROL 60초로는 그 60초를 못 막는다). 이름에 버전을 넣지 않은 이유는
  사이트당 블롭이 발행 횟수만큼 쌓이는데 지우는 코드가 없어서다
- site_thumbnail: 썸네일 전용 저장소 스위치(`THUMBNAIL_BLOB_*`). 예전엔 키 하나가
  사이트 전체 업로드(azure_static)까지 같이 켰다 — 둘은 필요한 저장소가 다르다
  (사이트는 정적 호스팅 `$web`, 썸네일은 이미지 버킷이면 된다)
- SitesPage: 줄 → **카드 그리드**. 썸네일은 16:10(브라우저 창 비율 — 사이트 미리보기를
  1:1 로 자르면 무슨 사이트인지 못 알아본다). 검색(상호·주소, 공백 무시) + 상태 칸
  `전체/발행됨/발행 전` 에 건수. 판정은 `bucketOf` 하나가 소유한다(배지·필터·정렬이 갈라지면
  건수가 어긋나 목록을 못 믿게 된다). 검색 0건 화면을 처음 온 사람의 빈 화면과 분리했다 —
  35개 있는데 "아직 없습니다" 라고 말하던 자리다
- 카드 골격은 **상태와 무관하게 같다**. 발행 전 카드에만 줄이 하나 더 붙어 높이와 버튼
  위치가 어긋났다(사장님 지적). 버튼 문구도 '편집' 하나로 — 하는 일이 같은데 글자만 달랐다

아직 그림이 없는 사이트가 대부분이다. 썸네일은 발행에 성공해야 생긴다.

검증: 백엔드 전체 통과. 목록 줄이 주소·생성일·썸네일을 들고 오는지, 발행 전 줄에
`thumbnail_url` 키가 아예 없는지, 재발행하면 `?v=1` → `?v=2` 로 주소가 바뀌는지 4건 추가.
프론트 tsc+eslint 통과. 실제 발행으로 블롭 업로드(232KB) → 공개 주소 200 → 목록 반영 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLWEFx4X3XRmKewUKjJWow
2026-09-08 13:01:47 +09:00
..
common [refactor] solution/backend,frontend,postgres-init: 회사(테넌트) 제거 — 사장님 계정이 곧 스코프 2026-09-08 13:01:14 +09:00
config [feat] solution/backend,frontend: id/pw 가입 · 구글 로그인 — 계정을 만들 길이 없던 걸 연다 2026-09-02 09:33:59 +09:00
crud [refactor] solution/backend,frontend,postgres-init: 회사(테넌트) 제거 — 사장님 계정이 곧 스코프 2026-09-08 13:01:14 +09:00
router [feat] solution/backend,frontend: 내 사이트 목록을 카드로 — 썸네일·주소·시각 · 발행마다 그림 갱신 2026-09-08 13:01:47 +09:00
scheduler 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다 2026-08-31 15:12:09 +09:00
scripts [refactor] solution/backend,frontend,postgres-init: 회사(테넌트) 제거 — 사장님 계정이 곧 스코프 2026-09-08 13:01:14 +09:00
services [feat] solution/backend,frontend: 내 사이트 목록을 카드로 — 썸네일·주소·시각 · 발행마다 그림 갱신 2026-09-08 13:01:47 +09:00
tests [feat] solution/backend,frontend: 내 사이트 목록을 카드로 — 썸네일·주소·시각 · 발행마다 그림 갱신 2026-09-08 13:01:47 +09:00
worker 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다 2026-08-31 15:12:09 +09:00
.env.example 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다 2026-08-31 15:12:09 +09:00
conftest.py [refactor] solution/backend,frontend,postgres-init: 회사(테넌트) 제거 — 사장님 계정이 곧 스코프 2026-09-08 13:01:14 +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 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다 2026-08-31 15:12:09 +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 실패 시 직전 값을 유지한다. 빈 값을 내보내지 않고 내부 알림만 발생시킨다.