문서: 앱을 가른 뒤 낡아진 서술을 고치고, 개발과 무관해진 기록을 지운다
지운 것 — 앞으로의 개발에 쓸 데가 없다. - solution/backend/demo_site.html: 어떤 스크립트도 만들지 않는 고아 산출물이고, 손으로 쓴 HTML 이라 "백엔드는 HTML 을 만들지 않는다" 와도 어긋난다. - solution/README.md: front/ · admin/.env · 사이트당 rooms/index.html·sitemap.xml 처럼 지금은 전부 틀린 서술이었다. 살아 있는 두 가지(빌더 CSR vs 발행물 SSG 대비표, 하이드레이션 블롭에 미검증 값이 샜던 실측)는 ARCHITECTURE 로 옮겼다. - docs/API_USAGE.md 의 Claude 개발비 집계: 2026-08-27 스냅샷과 재집계 스크립트는 일회성 지출 기록이라 제품 원가와 성격이 다르다. 문서를 외부 API 원가 하나로 좁혔다. 고친 것 — 코드를 따라가지 못하던 서술. - 코드 경로가 solution/backend 로 옮겨진 뒤 `backend/...` 로 남아 있던 포인터 전부. 가리키는 자리가 없는 경로는 문서가 아니라 함정이다. - ARCHITECTURE: 트리의 front→frontend, 컨테이너 표에 api-admin(:9801)·admin(:3002) 추가. - ★ ARCHITECTURE·AGENTS 의 "admin 전용 라우터가 0개" 는 사실이 아니었다. /v1/admin/local-content 가 admin 전용인데 :9800 에도 마운트돼 있다 — 포트를 가른 논리에 아직 남은 구멍이라 그렇게 적었다. - DECISIONS: 결론난 것을 미결로 두면 함정이 된다. 작업 큐(2026-08-27 결론), 날씨 캐시 TTL 1시간, jobs 테이블, media 조회 API, 수집 체인을 결론으로 옮기고 네이버 플레이스 대 TourAPI 실측(2026-08-31)을 1-1 에 이었다. - API_USAGE: 어댑터가 다 붙고 TourAPI 키도 나왔다. "실호출 0건" 은 낡은 서술이었다. - backend/README: 16→17 테이블(jobs), 없어진 alters/, MockAdapter 만이라는 서술, cd backend 경로, media·local 라우터 누락.
This commit is contained in:
parent
1856644972
commit
4871e50327
14
AGENTS.md
14
AGENTS.md
@ -78,12 +78,14 @@ admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
|
||||
| 사장님 API | 9800 | `solution/backend/web_main.py` | 엔드포인트별 |
|
||||
| 내부 API | 9801 | `admin/backend/main.py` → `app.py` | **앱 전체 role >= DEVELOPER** |
|
||||
|
||||
`services`·`crud`·`models` 은 그대로 공유한다. admin 전용 라우터가 0개라(세어봤다:
|
||||
admin 화면이 부르는 건 전부 place·fact 다) 도메인을 복제하지 않고 **같은 router 객체를
|
||||
다시 마운트하면서 앱 단위로 권한만 덧건다.**
|
||||
`services`·`crud`·`models` 은 그대로 공유한다. admin 화면이 부르는 게 사장님 빌더와 거의
|
||||
같아서(place·fact, admin 전용은 `local-content` 하나) 도메인을 복제하지 않고 **같은 router
|
||||
객체를 다시 마운트하면서 앱 단위로 권한만 덧건다.**
|
||||
경로 접두어(`/v1/admin/...`)가 아니라 **포트**를 가른 이유: 접두어는 같은 프로세스라
|
||||
사장님이 닿는 서버에 내부 엔드포인트가 존재한다. 포트를 가르면 아예 없다.
|
||||
`ADMIN_API_BIND` 기본값이 `127.0.0.1` 인 것도 같은 이유다 — 0.0.0.0 으로 열면 무의미하다.
|
||||
⚠️ 단 `/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다 — 남은 구멍이다
|
||||
([ARCHITECTURE.md 4절](docs/ARCHITECTURE.md)).
|
||||
|
||||
## 실행
|
||||
|
||||
@ -93,13 +95,13 @@ docker compose logs -f worker
|
||||
```
|
||||
|
||||
- 사장님 앱 `:3000`(API :9800) · 내부 운영 `:3002`(API :9801, 로컬호스트에만 열림)
|
||||
- 발행 사이트: `http://localhost:3000/s/<slug>` (front Vite 가 :3001 정적서버로 프록시)
|
||||
- 발행 사이트: `http://localhost:3000/s/<slug>` (빌더 Vite 가 :3001 정적서버로 프록시)
|
||||
- **클론 직후 1회**: `cp .env.example .env` · `cp nginx/site.conf.example nginx/site.conf`
|
||||
(후자를 빼먹으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다)
|
||||
- DB 는 compose 밖이다 (호스트 PostgreSQL, `host.docker.internal`).
|
||||
스키마는 `postgres-init/init-data/init.sql` **한 벌**이다 — 누적 ALTER 파일은 없다
|
||||
- npm 워크스페이스 루트는 **레포 루트**다. `npm install` 은 루트에서 한 번.
|
||||
`npm run dev:front` / `dev:admin` / `dev:site`
|
||||
`npm run dev:frontend` / `dev:admin` / `dev:site`
|
||||
- 백엔드 스크립트는 `solution/backend/` 에서 `.venv/bin/python scripts/<name>.py`
|
||||
- 테스트: `solution/backend/` 에서 `.venv/bin/pytest`. **`APP_ENV=test` 면 `.env` 를 읽지 않는다** —
|
||||
실키가 테스트로 새어 외부 API 요금이 나가는 경로를 막아 뒀다
|
||||
@ -112,7 +114,7 @@ Vite 앱은 구조상 **자기 디렉토리의 `.env` 만** 읽는다 — 그래
|
||||
| 파일 | 담는 것 |
|
||||
|---|---|
|
||||
| `.env` | DB · JWT · 외부 API 키 · `SITE_PUBLIC_HOST` · `INDEXNOW_KEY` |
|
||||
| `solution/frontend/.env` · `admin/.env` | 그 앱에만 있는 `VITE_*`. admin 은 API 가 :9801 이다 |
|
||||
| `solution/frontend/.env` · `admin/frontend/.env` | 그 앱에만 있는 `VITE_*`. admin 은 API 가 :9801 이다 |
|
||||
|
||||
★ **두 곳에 같은 값을 적지 않는다.** 발행 호스트는 compose 가 루트의 `SITE_PUBLIC_HOST` 를
|
||||
`VITE_PUBLISH_HOST` 로 흘려보낸다. 각자 적으면 canonical 과 화면 주소가 조용히 갈라진다.
|
||||
|
||||
@ -66,7 +66,7 @@ postgres-init/ 스키마 DDL
|
||||
| [docs/DEPLOY.md](docs/DEPLOY.md) | 서버에 올릴 때 · 배포 후 재발행 절차 |
|
||||
| [docs/DATA_SOURCE_RESEARCH.md](docs/DATA_SOURCE_RESEARCH.md) | 어디서 콘텐츠를 가져올 수 있나 (실측 근거) |
|
||||
| [docs/COLLECTION_SEO_AEO_FLOW.md](docs/COLLECTION_SEO_AEO_FLOW.md) | 수집→LLM→SEO/AEO 현재 구현 |
|
||||
| [docs/API_USAGE.md](docs/API_USAGE.md) | API 비용 — 제품 원가와 개발비 구분 |
|
||||
| [docs/API_USAGE.md](docs/API_USAGE.md) | 외부 API 원가 — 사이트 1건당 $1 상한을 어디서 강제하나 |
|
||||
|
||||
## 문서 규칙
|
||||
|
||||
|
||||
@ -1,210 +1,81 @@
|
||||
# API 사용 이력 · 비용 기록
|
||||
# 외부 API 원가
|
||||
|
||||
**집계 시각: 2026-08-27 08:59 KST**
|
||||
집계 대상: o2o-web4ai 프로젝트 전체 (backend · solution/frontend · solution/site)
|
||||
**사이트 1건을 만드는 데 나가는 외부 API 비용**만 다룬다. 상한은 **$1(1,400원)** 이고,
|
||||
문서가 아니라 코드가 강제한다(3절). 제품 제약으로서의 근거는 [PRODUCT.md 8절](PRODUCT.md).
|
||||
|
||||
> ⚠️ 이 상한에 **개발비를 섞지 않는다.** 코드를 짜는 데 든 LLM 요금은 일회성 지출이고,
|
||||
> 사이트를 만들 때마다 반복해서 나가는 원가와는 성격이 다르다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 먼저 — 섞으면 안 되는 두 가지 비용
|
||||
## 1. 공급자별 상태 (2026-08-31)
|
||||
|
||||
| | 무엇 | 언제 나가나 | 현재 |
|
||||
|---|---|---|---|
|
||||
| **A. 제품 원가** | 사이트 **1건 만들 때** 나가는 외부 API 비용 (Perplexity·Kakao·Gemini) | 업소 1곳 처리할 때마다 **반복** | **0원** (아직 한 번도 호출 안 함) |
|
||||
| **B. 개발비** | 이 코드를 **짜는 데** 쓴 Claude API 비용 | 개발 기간 동안 **한 번** | $70.07 |
|
||||
|
||||
**A 의 상한은 $1(1,400원)이다.** B 는 사이트를 만들 때마다 나가는 돈이 아니다 — 코드가 완성되면 끝나는 일회성 지출이다. 이 문서의 2절은 전부 **B** 얘기다.
|
||||
|
||||
---
|
||||
|
||||
## 0-1. 한 줄 요약
|
||||
|
||||
| 구분 | 실제 호출 | 비용 |
|
||||
|---|---|---|
|
||||
| **외부 유료 API** (Perplexity · Kakao · Gemini · TourAPI) | **0건** | **0원** |
|
||||
| **개발 도구 API** (Claude Code / Anthropic Messages API) | 348 요청 | **$70.07** (≈ 98,100원, 1,400원/USD 가정) |
|
||||
|
||||
지금까지 나간 돈은 **전부 개발 과정의 Claude API 요금**이고, 제품이 쓸 외부 API는 아직 **한 번도 실제로 때리지 않았다.**
|
||||
|
||||
---
|
||||
|
||||
## 1. [A] 제품 원가 — 외부 API 계약 현황과 실호출 이력
|
||||
|
||||
### 1-1. 키 상태
|
||||
|
||||
| API | 용도 | 키 설정 | 어댑터 | 실호출 |
|
||||
| API | 용도 | 키 | 붙은 자리 | 과금 |
|
||||
|---|---|---|---|---|
|
||||
| Perplexity Sonar (`api.perplexity.ai`) | 채널 URL 발견 | ✅ 설정됨 | `backend/services/external/perplexity.py` 구현 완료 | **0건** |
|
||||
| Kakao Local (`dapi.kakao.com`) | 동일 업소 검증 · 주변 정보 | ⬜ 비어 있음 | 미구현 (설계만) | **0건** |
|
||||
| Gemini (Google AI Studio) | 사진 분류 · 카피 작성 | ✅ 설정됨 | 미구현 | **0건** |
|
||||
| TourAPI (`data.go.kr`) | 축제 · 관광지 | ⬜ 비어 있음 | 미구현 | **0건** |
|
||||
| Open-Meteo | 날씨 | 키 불필요 | `frontend/.../useWeather.ts` | **0건** (프론트 코드만 작성) |
|
||||
| **TourAPI** (`apis.data.go.kr`) | fact 주력 공급원 — 숙박·음식점 | ✅ | `collector/tour_api_adapter.py` | **무료** (개발계정 1,000건/일) |
|
||||
| **네이버 지역검색** (`openapi.naver.com`) | 동일 업소 검증 후보 | ✅ | `external/naver.py` | 무료 쿼터 |
|
||||
| **Perplexity Sonar** | 채널 URL 발견 | ✅ | `external/perplexity.py` | 요청 + 토큰 |
|
||||
| **Gemini** (AI Studio) | 사진 분류 · 소개문/FAQ · 붙여넣기 추출 | ✅ | `external/gemini.py` `gemini_text.py` `gemini_extract.py` | 토큰 |
|
||||
| **Kakao Local** (`dapi.kakao.com`) | 주변 정보 | ⬜ 미발급 | `external/kakao.py` | 건당 2원 |
|
||||
| **Open-Meteo** | 날씨 | 불필요 | `external/open_meteo.py` | 무료 |
|
||||
|
||||
> `.env` 의 키가 비어 있으면 해당 어댑터만 비활성되고 서버는 그대로 뜬다 — 설계상 의도된 동작.
|
||||
`.env` 의 키가 비어 있으면 **해당 어댑터만 비활성되고 서버는 그대로 뜬다** — 부팅이 외부 계약에
|
||||
묶이면 안 되기 때문이다. 수집 어댑터는 `COLLECT_ADAPTERS` 로 배포 없이 개별로 끌 수 있다.
|
||||
|
||||
### 1-2. 실호출이 0건인 근거
|
||||
무엇을 어디서 가져오는지(필드 단위 카탈로그와 실측 충전율)는
|
||||
[DATA_SOURCE_RESEARCH.md 8-3](DATA_SOURCE_RESEARCH.md).
|
||||
|
||||
1. **코드 레벨** — 전체 세션 트랜스크립트에서 위 호스트로 나간 HTTP 요청이 없다. 등장하는 건 전부 소스 작성·문서 편집 텍스트다.
|
||||
2. **테스트 레벨** — `backend/tests/` 안에서 실제 네트워크를 타는 코드가 없다. `test_perplexity.py` · `test_collector.py` 는 전부 목(mock) 기반이고, `httpx.get/post` 직접 호출은 0건.
|
||||
3. **설정 레벨** — `DECISIONS.md` 의 결정대로 **`APP_ENV=test` 면 `.env` 를 읽지 않는다.** 실키가 테스트로 새어 외부 API 요금이 나가는 경로 자체를 막아 뒀다.
|
||||
4. **DB 레벨** — 어떤 테이블에도 외부 API 응답이 적재된 흔적이 없다 (호출 로그 테이블도 아직 없다 — 3-2 참고).
|
||||
## 2. 단가표
|
||||
|
||||
### 1-3. 단가표 (호출 시작 시 적용될 요금)
|
||||
코드의 단일 출처는 `solution/backend/common/cost.py` 의 `RATES` 다. 아래는 그 요약이다.
|
||||
|
||||
| API | 과금 방식 | 단가 |
|
||||
| API | 단가 | 확정 |
|
||||
|---|---|---|
|
||||
| Perplexity Sonar | **토큰 요금 + 검색 호출 요금이 별도로 붙는다** | `sonar` 기본, `sonar-pro` 는 더 비쌈. 호출당 검색 횟수는 이미 로그로 남기고 있음 (`perplexity.py` 의 `usage.num_search_queries`) |
|
||||
| Kakao Local | 무료 쿼터 초과분 종량 | 키워드/카테고리 검색 **2원**, 좌표 변환 **0.5원** (키워드가 4배 비싸다) |
|
||||
| Gemini | 토큰 종량 (Google AI Studio) | 모델·티어별 |
|
||||
| TourAPI | 공공데이터 무료 (일 트래픽 제한) | 0원 |
|
||||
| Open-Meteo | 무료 | 0원 |
|
||||
| Kakao Local | 키워드/카테고리 검색 **2원**, 좌표 변환 **0.5원** | ✅ `.env.example` |
|
||||
| Perplexity Sonar | 요청 7원 + 1K 토큰당 1.4원 (low context, USD_KRW 1,400) | ✅ 공식 단가표 |
|
||||
| Gemini | 이미지 1원 · 1K 토큰 0.5원 | ❌ **추정치** |
|
||||
| TourAPI · Open-Meteo | 0원 | ✅ |
|
||||
|
||||
> ⚠️ **Kakao 무료 쿼터 함정** — 무료 쿼터는 개발자 계정의 "첫 번째 활성 앱" 에만 붙는다. dev/stage/prod 앱을 따로 파면 **하나만 무료**다. 앱을 나누기 전에 확인할 것.
|
||||
★ **좌표 변환이 키워드 검색보다 4배 싸다.** 같은 공급자인데 단가가 다르므로 호출측이
|
||||
`kakao_coord` 로 구분해 넘긴다. 지역 정보를 `place_id` 가 아니라 **행정구역 코드로 캐싱**하는
|
||||
이유도 이것이다 — 같은 지역에 사이트 50개가 생겨도 외부 조회는 1회다.
|
||||
|
||||
---
|
||||
⚠️ **Kakao 무료 쿼터 함정** — 무료 쿼터는 개발자 계정의 "첫 번째 활성 앱" 에만 붙는다.
|
||||
dev/stage/prod 앱을 따로 파면 **하나만 무료**다. 앱을 나누기 전에 확인할 것.
|
||||
|
||||
## 2. [B] 개발비 — Claude (Anthropic Messages API)
|
||||
|
||||
> 사이트 1건당 원가가 **아니다.** 코드를 짜는 데 든 일회성 비용이다.
|
||||
|
||||
### 2-1. 총계
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 기간 | 2026-08-26 16:23 ~ 2026-08-27 08:58 KST (약 16.6시간) |
|
||||
| 모델 | `claude-opus-5` (1M 컨텍스트) 단일 |
|
||||
| 과금 요청 수 | **348건** (메시지 ID 기준 중복 제거) |
|
||||
| 출력 토큰 | 450,509 |
|
||||
| 캐시 쓰기 (1h TTL) | 2,020,375 |
|
||||
| 캐시 쓰기 (5m TTL) | 96,213 |
|
||||
| 캐시 읽기 | 76,004,125 |
|
||||
| 캐시 미적용 입력 | 690 |
|
||||
| 입력 환산 합계 | 78,121,403 |
|
||||
| **총 비용** | **$70.07** |
|
||||
|
||||
### 2-2. 비용 내역
|
||||
|
||||
| 항목 | 토큰 | 요율 ($/MTok) | 금액 |
|
||||
|---|---|---|---|
|
||||
| 캐시 읽기 (기본 입력가 ×0.1) | 76,004,125 | $0.50 | **$38.00** |
|
||||
| 캐시 쓰기 1h (기본 입력가 ×2) | 2,020,375 | $10.00 | **$20.20** |
|
||||
| 출력 | 450,509 | $25.00 | **$11.26** |
|
||||
| 캐시 쓰기 5m (기본 입력가 ×1.25) | 96,213 | $6.25 | **$0.60** |
|
||||
| 캐시 미적용 입력 | 690 | $5.00 | $0.003 |
|
||||
| | | **합계** | **$70.07** |
|
||||
|
||||
> 각 항목은 센트 단위 반올림이라, 눈으로 더하면 합계와 1센트 차이가 날 수 있다.
|
||||
|
||||
**캐시 효과** — 프롬프트 캐싱이 없었다면 같은 작업이 **$401.87** 이었다. 즉 **$331.80 (82.6%) 를 캐시로 아꼈다.**
|
||||
동시에, 비용의 **54%($38.00)가 캐시 *읽기*** 다. 출력 토큰 값($11.26)의 3배가 넘는다 — 긴 컨텍스트를 반복해서 다시 읽는 패턴이 이 프로젝트 비용 구조를 지배한다는 뜻이다. 줄이려면 출력을 줄이는 게 아니라 **컨텍스트를 작게 유지**해야 한다.
|
||||
|
||||
### 2-3. 세션별 내역
|
||||
|
||||
| 세션 | 종류 | 시각 (KST) | 요청 | 출력 토큰 | 캐시 읽기 | 비용 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `4db7aca5` | 메인 (backend 주력) | 08-26 16:23 ~ 08-27 08:55 | 157 | 266,385 | 35,748,750 | **$32.65** |
|
||||
| `99be2a6c` | 메인 | 08-26 17:24 ~ 08-27 08:58 | 92 | 124,641 | 16,660,246 | **$19.79** |
|
||||
| `19be092e` | 메인 (solution/frontend) | 08-27 08:42 ~ 08:58 | 29 | 25,903 | 2,476,298 | $3.37 |
|
||||
| `agent-a58…` | 서브에이전트 | 08-27 08:51 ~ 08:57 | 13 | 6,173 | 5,741,359 | $3.21 |
|
||||
| `agent-afa…` | 서브에이전트 | 08-27 08:52 ~ 08:55 | 14 | 423 | 6,132,381 | $3.21 |
|
||||
| `6052d29a` | 메인 (이 기록 작성 세션) | 08-27 08:52 ~ 08:58 | 21 | 23,936 | 1,678,581 | $2.92 |
|
||||
| `agent-aa5…` | 서브에이전트 | 08-27 08:52 ~ 08:58 | 9 | 68 | 3,905,434 | $2.07 |
|
||||
| `agent-ab1…` | 서브에이전트 | 08-27 08:52 ~ 08:57 | 8 | 165 | 3,466,831 | $1.90 |
|
||||
| `d56c8333` | 메인 | 08-27 08:52 ~ 08:53 | 5 | 2,815 | 194,245 | $0.95 |
|
||||
| `2ac02dde` | 메인 (중복 세션) | — | 0 | 0 | 0 | $0.00 |
|
||||
|
||||
**읽을 거리 두 가지:**
|
||||
|
||||
- **서브에이전트 4개 합계 $10.39** — Perplexity/Kakao 클라이언트를 병렬로 짠 fork 들이다. 출력 토큰은 다 합쳐 6,829개뿐인데 캐시 읽기가 **1,925만 토큰**이다. **fork 는 부모 컨텍스트를 통째로 들고 시작하기 때문에**, 실제 작업이 짧아도 부모 컨텍스트가 크면 읽기 요금이 그만큼 붙는다. 병렬 fork 를 쓸지 말지는 "일이 병렬인가" 보다 **"부모 컨텍스트가 큰가"** 로 판단하는 편이 비용상 맞다.
|
||||
- **`2ac02dde` 는 `99be2a6c` 로 이어진 세션(fork/resume)이라 메시지가 전부 중복** — 0원으로 처리했다. 세션 간 중복을 제거하지 않으면 408건 / **$79.16** 이 나와 **$9.09 과대집계**된다. 재집계할 때 메시지 ID 중복 제거를 반드시 넣을 것.
|
||||
|
||||
### 2-4. 이 숫자를 읽을 때 주의할 점
|
||||
|
||||
- **구독제(Max/Pro)로 쓰고 있다면 이 금액이 그대로 청구되지는 않는다.** 토큰 사용량을 공개 API 요율로 환산한 "이만큼 썼다" 값이다. 종량제 API 키로 같은 작업을 돌렸다면 나왔을 금액.
|
||||
- 요율 기준: Opus 5 입력 $5 / 출력 $25 per MTok, 캐시 읽기 ×0.1, 캐시 쓰기 5m ×1.25 · 1h ×2.
|
||||
- 1M 컨텍스트 구간에 별도 프리미엄이 붙는 경우 실제 금액은 이보다 높을 수 있다 — **하한선으로 읽을 것.**
|
||||
- 원화 환산은 1,400원/USD 가정이다. 실제 청구 환율과 다를 수 있다.
|
||||
- **이 스냅샷은 찍는 순간에도 움직인다.** 집계 시각 기준으로 `4db7aca5`·`99be2a6c`·`19be092e`·`6052d29a` 등 여러 세션이 동시에 살아 있었다. 4절의 스크립트로 언제든 다시 뽑을 것.
|
||||
|
||||
---
|
||||
|
||||
## 3. 앞으로 할 일
|
||||
|
||||
### 3-1. 비용이 실제로 발생하기 시작하는 지점
|
||||
|
||||
수집 파이프라인이 도는 순간부터다:
|
||||
|
||||
```
|
||||
상호명 → [Perplexity: 채널 URL 발견] ← 토큰 + 검색 호출 과금
|
||||
→ [Kakao Local: 동일 업소 검증] ← 건당 2원
|
||||
→ 크롤링(무료)
|
||||
→ [Gemini: 사진 분류 + 카피] ← 토큰 과금
|
||||
```
|
||||
|
||||
업소 1건당 Perplexity 1~2회 + Kakao 여러 건 + Gemini(사진 수만큼)가 붙는다. **업소당 원가를 먼저 실측한 뒤 배치를 키우는 순서**가 안전하다.
|
||||
|
||||
### 3-2. 사이트 1건당 예산: **$1 (1,400원)** — 코드로 강제한다
|
||||
|
||||
제품 제약이 정해졌다: **업소 1곳의 사이트를 만드는 외부 API 비용은 $1 을 넘으면 안 된다.**
|
||||
|
||||
문서에만 적으면 지켜지지 않으므로 가드를 구현했다 — `backend/common/cost.py` (`CostMeter`), 테스트 `backend/tests/test_cost.py` 8건.
|
||||
## 3. 상한을 코드가 강제한다
|
||||
|
||||
```python
|
||||
meter = CostMeter(place_id=42)
|
||||
meter.guard(Provider.PERPLEXITY, searches=2, tokens=3000) # 호출 "전" 에 확인 → 넘으면 BudgetExceeded
|
||||
meter.guard(Provider.PERPLEXITY, searches=2, tokens=3000) # 호출 "전" → 넘으면 BudgetExceeded
|
||||
...실제 호출...
|
||||
meter.charge(Provider.PERPLEXITY, searches=2, tokens=3100) # 호출 "후" 에 실측 기록
|
||||
meter.charge(Provider.PERPLEXITY, searches=2, tokens=3100) # 호출 "후" 실측 기록
|
||||
```
|
||||
|
||||
- `guard()` 가 막으면 **그 호출을 하지 않는다.** 이미 쓴 돈은 못 돌려받지만 손실이 선형으로 늘어나는 걸 끊는다.
|
||||
- 예산 70% 소진 시 경고 로그, 100% 초과 시 예외.
|
||||
- 미터 1개 = 사이트 1건.
|
||||
- `guard()` 가 막으면 **그 호출을 하지 않는다.** 이미 쓴 돈은 못 돌려받지만 손실이 선형으로
|
||||
늘어나는 걸 끊는다.
|
||||
- 예산 70% 소진 시 경고 로그, 100% 초과 시 예외. **미터 1개 = 사이트 1건.**
|
||||
- `solution/backend/common/cost.py` · 테스트 `tests/test_cost.py` 8건.
|
||||
|
||||
**⚠️ 단가표에 아직 추정치가 섞여 있다.** 확정된 건 카카오뿐이다(레포에 2원/0.5원이 적혀 있다). Perplexity·Gemini 는 공식 단가표를 확인해서 `RATES` 를 교체해야 한다. 그때까지 `assert_rates_confirmed()` 가 실배치를 막는다 — 추정치 위에 "예산 안에 들어온다" 는 결론을 세우지 않기 위해서다.
|
||||
⚠️ **Gemini 단가가 아직 추정치다.** 그래서 `assert_rates_confirmed()` 가 실배치를 막는다 —
|
||||
추정치 위에 "예산 안에 들어온다" 는 결론을 세우지 않기 위해서다. 공식 단가표로 `RATES` 를
|
||||
교체하면서 `confirmed=True` 로 바꾼다.
|
||||
|
||||
### 3-3. 지금 없는 것 — 호출 로그 테이블
|
||||
비용이 실제로 붙는 순서:
|
||||
|
||||
현재 DB 스키마(`backend/common/database/model/models.py`)에 **API 호출·비용을 적재하는 테이블이 없다.** 지금은 애플리케이션 로그로만 남는다 (`perplexity.py` 가 `tokens=` 와 검색 횟수를 찍는 정도).
|
||||
|
||||
파이프라인을 돌리기 전에 `api_call_logs` 같은 테이블(공급자 · 엔드포인트 · 토큰/호출수 · 추정단가 · place_id · 시각)을 얹어두면, 이 문서를 손으로 갱신하지 않고 쿼리로 뽑을 수 있다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 재집계 방법
|
||||
|
||||
Claude 사용액은 아래로 다시 뽑는다 (세션 트랜스크립트 기준, 메시지 ID로 중복 제거):
|
||||
|
||||
```bash
|
||||
cd ~/.claude/projects && python3 - <<'PY'
|
||||
import json,glob,collections,os
|
||||
files=sorted(glob.glob("-Users-minachoi-projects-o2o-web4ai*/*.jsonl")
|
||||
+ glob.glob("-Users-minachoi-projects-o2o-web4ai*/*/subagents/*.jsonl"),
|
||||
key=lambda p:-os.path.getsize(p))
|
||||
IN,OUT,CR,W5,W1H = 5.0/1e6, 25.0/1e6, 0.50/1e6, 6.25/1e6, 10.0/1e6 # Opus 5 요율
|
||||
seen=set(); t=collections.Counter()
|
||||
for f in files:
|
||||
for line in open(f,encoding="utf-8",errors="replace"):
|
||||
try: o=json.loads(line)
|
||||
except: continue
|
||||
m=o.get("message") or {}; u=m.get("usage")
|
||||
if not u: continue
|
||||
rid=m.get("id")
|
||||
if rid:
|
||||
if rid in seen: continue # ★ fork/resume 중복 제거 — 빠뜨리면 과대집계
|
||||
seen.add(rid)
|
||||
cc=u.get("cache_creation") or {}
|
||||
w5=cc.get("ephemeral_5m_input_tokens",0); w1=cc.get("ephemeral_1h_input_tokens",0)
|
||||
if w5+w1==0: w5=u.get("cache_creation_input_tokens",0)
|
||||
t["in"]+=u.get("input_tokens",0); t["out"]+=u.get("output_tokens",0)
|
||||
t["w5"]+=w5; t["w1h"]+=w1; t["cr"]+=u.get("cache_read_input_tokens",0); t["n"]+=1
|
||||
cost=t["in"]*IN+t["out"]*OUT+t["w5"]*W5+t["w1h"]*W1H+t["cr"]*CR
|
||||
print(dict(t)); print(f"요청 {t['n']}건 / ${cost:.2f}")
|
||||
PY
|
||||
```
|
||||
상호명 → [Perplexity: 채널 URL 발견] ← 요청 + 토큰
|
||||
→ [TourAPI: fact 수집] ← 무료
|
||||
→ 크롤링(무료)
|
||||
→ [Gemini: 사진 분류 + 카피] ← 토큰 (사진 수에 비례)
|
||||
```
|
||||
|
||||
외부 API 실호출 여부 확인:
|
||||
**업소당 원가를 먼저 실측한 뒤 배치를 키우는 순서**가 안전하다.
|
||||
|
||||
```bash
|
||||
cd ~/.claude/projects && grep -c "api.perplexity.ai\|generativelanguage.googleapis.com\|dapi.kakao.com" \
|
||||
-- -Users-minachoi-projects-o2o-web4ai*/*.jsonl
|
||||
# 매칭이 있어도 대부분 소스 작성 텍스트다 — tool_use 의 command/url 필드인지 확인할 것
|
||||
```
|
||||
## 4. 아직 없는 것 — 호출 로그 테이블
|
||||
|
||||
DB(`solution/backend/common/database/model/models.py`, 17테이블)에 **API 호출·비용을 적재하는
|
||||
테이블이 없다.** 지금은 애플리케이션 로그로만 남는다(`perplexity.py` 가 토큰과 검색 횟수를 찍는 정도).
|
||||
|
||||
파이프라인을 배치로 돌리기 전에 `api_call_logs`(공급자 · 엔드포인트 · 토큰/호출수 · 추정단가 ·
|
||||
`place_id` · 시각)를 얹어두면, 이 문서를 손으로 갱신하지 않고 쿼리로 뽑을 수 있다.
|
||||
|
||||
@ -21,6 +21,12 @@ React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포
|
||||
|
||||
계약의 타입은 `solution/shared/src/types/site-payload.ts` 하나다.
|
||||
|
||||
⚠️ **payload 는 화면·JSON-LD 뿐 아니라 하이드레이션 블롭으로도 HTML 에 통째로 박힌다.**
|
||||
그래서 거르는 자리가 `solution/shared/src/lib/facts.ts` **한 곳**이다 —
|
||||
`sanitizePayloadForPublish()` 를 프리렌더가 먼저 통과시킨 뒤 렌더와 임베드 양쪽에 쓴다.
|
||||
화면과 JSON-LD 만 걸렀더니 그 블롭에 미검증 값이 남아 **원본 HTML 을 읽는 AI 가 그걸 읽었다**
|
||||
— 실제로 한 번 그렇게 샜고, 그래서 이 함수가 생겼다.
|
||||
|
||||
## 2. 발행 파이프라인
|
||||
|
||||
```
|
||||
@ -95,9 +101,9 @@ nginx 는 `try_files $uri $uri/index.html =404` 로 같은 규칙을 맞춘다
|
||||
o2o-web4ai/
|
||||
├─ solution/ 사장님 — 사이트 만들기·관리
|
||||
│ ├─ backend/ FastAPI + 워커 · 이 레포의 유일한 백엔드
|
||||
│ ├─ front/ 빌더 (위저드 + 에디터 + 발행 게이트)
|
||||
│ ├─ frontend/ 빌더 (위저드 + 에디터 + 발행 게이트)
|
||||
│ ├─ site/ 발행 정적 사이트 (SSR 엔트리 + 프리렌더)
|
||||
│ └─ shared/ front·site·백엔드 계약 (SitePayload · slug · 토큰)
|
||||
│ └─ shared/ frontend·site·백엔드 계약 (SitePayload · slug · 토큰)
|
||||
│
|
||||
├─ admin/ 우리 — 전체 사이트 운영
|
||||
│ ├─ backend/ 내부 API 진입점(:9801). 도메인 코드는 solution/backend
|
||||
@ -111,6 +117,19 @@ o2o-web4ai/
|
||||
negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를 가르는 선례,
|
||||
`lps-admin/` 이 **백엔드 없이 프론트만 가진 최상단 폴더**의 선례다 — `admin/` 이 후자다.
|
||||
|
||||
### `frontend`(빌더)와 `site`(발행물)는 요구가 정반대다
|
||||
|
||||
같은 `solution/` 안에 있지만 빌드를 나눈 이유가 이 표다. 보일러플레이트는 순수 CSR 이라
|
||||
발행 사이트에 그대로 쓰면 크롤러가 `<div id="root"></div>` 만 읽고 떠난다.
|
||||
|
||||
| | `frontend/` (빌더) | `site/` (발행 사이트) |
|
||||
|---|---|---|
|
||||
| 사용자 | 사장님 | 손님 · **검색/AI 크롤러** |
|
||||
| 렌더링 | CSR SPA | **SSG (정적 HTML)** |
|
||||
| 색인 | `noindex` | 색인·인용되라고 존재 |
|
||||
| 런타임 | Vite dev / 정적 호스팅 | **서버 없음.** 파일만 |
|
||||
| 데이터 | 편집 중 상태(미확인 값 포함) | `SitePayload` — **확인된 값만** |
|
||||
|
||||
### 왜 갈랐나 — 취향이 아니라 셋 다 실제 문제였다
|
||||
|
||||
1. **내부 기능이 사장님 번들에 실려 나갔다.** 한 앱이면 `/local-content`, `/places/:id/seo`
|
||||
@ -132,16 +151,18 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를
|
||||
| 내부 API | **9801** | `admin/backend/main.py` → `app.py` | **앱 전체 `role >= DEVELOPER`** |
|
||||
|
||||
`services`·`crud`·`models` 은 공유한다 — `admin/backend` 는 진입점 두 파일뿐이고,
|
||||
도메인 코드는 `PYTHONPATH=/app/solution/backend` 로 그대로 import 한다. **admin 전용 라우터가 0개**이기 때문이다 — 세어봤다:
|
||||
도메인 코드는 `PYTHONPATH=/app/solution/backend` 로 그대로 import 한다. admin 화면이 부르는
|
||||
엔드포인트가 **사장님 빌더가 쓰는 것과 거의 같기 때문**이다 — 세어봤다:
|
||||
|
||||
```
|
||||
useGetPlace · useListPlaces · useListLinks · useConfirmLink → router/v1/place
|
||||
useListFacts · useGetSchema · useTransitionFact → router/v1/fact
|
||||
/v1/admin/local-content (customFetch 직접 호출) → router/v1/local ← 유일한 admin 전용
|
||||
```
|
||||
|
||||
admin 화면이 부르는 게 전부 `place`·`fact` 이고, 그 둘은 사장님 빌더도 쓴다. 자체 백엔드에
|
||||
엔드포인트를 새로 쓰면 **같은 DB 의 같은 테이블을 두 벌** 구현하는 것뿐이다.
|
||||
그래서 같은 router 객체를 다시 마운트하고 **앱 단위로 권한만 덧건다.**
|
||||
`place`·`fact` 는 사장님 빌더도 쓴다. 자체 백엔드에 엔드포인트를 새로 쓰면 **같은 DB 의 같은
|
||||
테이블을 두 벌** 구현하는 것뿐이다. 그래서 같은 router 객체를 다시 마운트하고
|
||||
**앱 단위로 권한만 덧건다.**
|
||||
|
||||
**왜 경로 접두어(`/v1/admin/...`)가 아니라 포트인가.** 접두어는 같은 프로세스 안이라
|
||||
사장님이 닿는 서버에 내부 엔드포인트가 **존재한다.** 포트를 가르면 사장님이 닿는
|
||||
@ -158,11 +179,15 @@ OWNER role=2 → 403
|
||||
OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다.
|
||||
`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다).
|
||||
|
||||
⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`).
|
||||
위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 가
|
||||
사장님(USER)을 막고 있을 뿐이라, **가른 의미가 여기서만 절반이다.** 내리는 것이 남은 일이다.
|
||||
|
||||
→ 대가: `solution/backend` 의 코드에 묶인다. 배포는 갈리지만 소스는 한 벌이다.
|
||||
|
||||
### 두 앱이 코드를 나눠 갖는 방식 — `@` 가 solution 을 가리킨다
|
||||
|
||||
`admin/vite.config.ts` 와 `tsconfig.json` 에서 **`@` 는 `solution/frontend/src`** 다.
|
||||
`admin/frontend/vite.config.ts` 와 `tsconfig.json` 에서 **`@` 는 `solution/frontend/src`** 다.
|
||||
admin 자기 파일만 `@admin` 이다.
|
||||
|
||||
왜 복제하지 않았나: 내부 화면이 쓰는 API 클라이언트·UI 프리미티브·수집 배선이 solution 에
|
||||
@ -201,6 +226,10 @@ admin 자기 파일만 `@admin` 이다.
|
||||
그때 `solution/frontend` 의 인증 정책을 다시 본다.
|
||||
- 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을
|
||||
`127.0.0.1` 로 두었다. **0.0.0.0 으로 열면 앱을 가른 의미가 없다.**
|
||||
- **폰트 self-host** — `solution/site/public/fonts/PretendardVariable.woff2` 가 없어 Noto Sans KR 로
|
||||
폴백된다. `@font-face` 가 조용히 실패하는 것이라 빌드는 안 깨진다.
|
||||
- **이미지 최적화** — 원본 URL 을 그대로 쓴다(`srcset`·WebP 없음). media 파이프라인이 우리 쪽
|
||||
저장소를 갖게 된 뒤의 일이다 ([DEPLOY.md 1절](DEPLOY.md) 의 핫링크 리스크와 같은 항목).
|
||||
|
||||
## 5. 산출물 — 사이트 하나 = 한 장
|
||||
|
||||
@ -247,9 +276,11 @@ DB 에 HTML 컬럼은 없다 (`site_versions.snapshot` JSONB 가 원본).
|
||||
|
||||
| 컨테이너 | 무엇 | 포트 |
|
||||
|---|---|---|
|
||||
| `o2o-web4ai-api` | FastAPI — 화면이 부르는 API | 9800 |
|
||||
| `o2o-web4ai-api` | 사장님 API (`solution/backend/web_main.py`) | 9800 |
|
||||
| `o2o-web4ai-api-admin` | 내부 API (`admin/backend/main.py`) — 앱 전체 `role >= DEVELOPER` | 9801 (기본 `127.0.0.1`) |
|
||||
| `o2o-web4ai-worker` | 잡 러너 (BUILD·수집·생성) + 스케줄러 | — |
|
||||
| `o2o-web4ai-web` | admin Vite + `watch-payloads` 프리렌더 + `serve-sites`(개발용 :3001) | 3000 |
|
||||
| `o2o-web4ai-web` | 사장님 빌더 Vite + `watch-payloads` 프리렌더 + `serve-sites`(개발용 :3001) | 3000 |
|
||||
| `o2o-web4ai-admin` | 내부 운영 화면 Vite | 3002 (기본 `127.0.0.1`) |
|
||||
| `o2o-web4ai-nginx` | **발행 사이트 정적 서빙** — `site-out` 볼륨을 읽기 전용으로 | 80 |
|
||||
|
||||
DB(PostgreSQL)는 **compose 밖**이다 — 호스트에서 돌고 `host.docker.internal` 로 붙는다.
|
||||
|
||||
@ -34,8 +34,8 @@
|
||||
|
||||
주요 코드:
|
||||
|
||||
- `backend/services/place_service.py`
|
||||
- `backend/router/v1/place/`
|
||||
- `solution/backend/services/place_service.py`
|
||||
- `solution/backend/router/v1/place/`
|
||||
|
||||
## 3. 채널 URL 확보
|
||||
|
||||
@ -58,8 +58,8 @@ Perplexity 응답의 영업시간, 가격 같은 본문 정보는 사용하지
|
||||
|
||||
주요 코드:
|
||||
|
||||
- `backend/services/external/perplexity.py`
|
||||
- `backend/services/prompts/channel_discovery.py`
|
||||
- `solution/backend/services/external/perplexity.py`
|
||||
- `solution/backend/services/prompts/channel_discovery.py`
|
||||
- `solution/frontend/src/features/onboarding/useCollectFlow.ts`
|
||||
- `solution/frontend/src/features/onboarding/ChannelConfirmPanel.tsx`
|
||||
|
||||
@ -83,14 +83,14 @@ Perplexity 응답의 영업시간, 가격 같은 본문 정보는 사용하지
|
||||
- 숙박: 객실, 입·퇴실, 인원, 편의시설 등
|
||||
- 관광·체험: 프로그램, 장비, 운영 정보 등
|
||||
|
||||
정확한 필드 목록은 `backend/common/category_schema.py`가 관리한다. 어댑터가 값을 찾더라도 업종 스키마에 없는 항목은 정식 fact로 사용하지 않는다.
|
||||
정확한 필드 목록은 `solution/backend/common/category_schema/`(업종별 JSON + 로더)가 관리한다. 어댑터가 값을 찾더라도 업종 스키마에 없는 항목은 정식 fact로 사용하지 않는다.
|
||||
|
||||
주요 코드:
|
||||
|
||||
- `backend/services/collect_service.py`
|
||||
- `backend/services/collector/`
|
||||
- `backend/common/category_schema.py`
|
||||
- `backend/services/fact_service.py`
|
||||
- `solution/backend/services/collect_service.py`
|
||||
- `solution/backend/services/collector/`
|
||||
- `solution/backend/common/category_schema/`
|
||||
- `solution/backend/services/fact_service.py`
|
||||
|
||||
## 5. 정보 승인
|
||||
|
||||
@ -117,9 +117,9 @@ Perplexity 응답의 영업시간, 가격 같은 본문 정보는 사용하지
|
||||
|
||||
관련 파일:
|
||||
|
||||
- `backend/services/external/gemini.py`
|
||||
- `backend/services/prompts/vision.py`
|
||||
- `backend/services/vision_service.py`
|
||||
- `solution/backend/services/external/gemini.py`
|
||||
- `solution/backend/services/prompts/vision.py`
|
||||
- `solution/backend/services/vision_service.py`
|
||||
|
||||
### Gemini Text
|
||||
|
||||
@ -142,9 +142,9 @@ Perplexity 응답의 영업시간, 가격 같은 본문 정보는 사용하지
|
||||
|
||||
관련 파일:
|
||||
|
||||
- `backend/services/external/gemini_text.py`
|
||||
- `backend/services/prompts/copy.py`
|
||||
- `backend/services/copy_service.py`
|
||||
- `solution/backend/services/external/gemini_text.py`
|
||||
- `solution/backend/services/prompts/copy.py`
|
||||
- `solution/backend/services/copy_service.py`
|
||||
|
||||
## 7. 정적 빌드와 발행
|
||||
|
||||
@ -166,10 +166,10 @@ Perplexity 응답의 영업시간, 가격 같은 본문 정보는 사용하지
|
||||
|
||||
관련 파일:
|
||||
|
||||
- `backend/services/build_service.py`
|
||||
- `backend/services/snapshot.py`
|
||||
- `backend/services/site_payload.py`
|
||||
- `backend/services/publish_gate.py`
|
||||
- `solution/backend/services/build_service.py`
|
||||
- `solution/backend/services/snapshot.py`
|
||||
- `solution/backend/services/site_payload.py`
|
||||
- `solution/backend/services/publish_gate.py`
|
||||
- `solution/site/`
|
||||
|
||||
## 8. SEO 구현
|
||||
@ -215,11 +215,11 @@ JSON-LD 값은 화면에도 동일하게 존재해야 한다. 렌더러가 양
|
||||
- `solution/site/src/seo/llms.ts`
|
||||
- `solution/site/src/seo/verify.ts`
|
||||
- `solution/site/src/sections/AnswerBlock.tsx`
|
||||
- `backend/services/publish_gate.py`
|
||||
- `solution/backend/services/publish_gate.py`
|
||||
|
||||
## 10. SEO·AEO 진단 점수
|
||||
|
||||
관리자 화면은 현재 저장 정보와 최신 발행본을 기준으로 SEO와 AEO 준비도를 각각 100점으로 계산한다.
|
||||
내부 운영 화면(`admin`)은 현재 저장 정보와 최신 발행본을 기준으로 SEO와 AEO 준비도를 각각 100점으로 계산한다.
|
||||
|
||||
SEO 항목:
|
||||
|
||||
@ -246,9 +246,9 @@ AEO 항목:
|
||||
|
||||
관련 파일:
|
||||
|
||||
- `backend/services/seo_audit.py`
|
||||
- `backend/router/v1/site/site.py`
|
||||
- `solution/frontend/src/pages/SeoAuditPage.tsx`
|
||||
- `solution/backend/services/seo_audit.py`
|
||||
- `solution/backend/router/v1/site/site.py`
|
||||
- `admin/frontend/src/pages/SeoAuditPage.tsx`
|
||||
|
||||
API:
|
||||
|
||||
|
||||
@ -318,11 +318,11 @@ URL → StaticHtmlAdapter (robots 준수) ─┐
|
||||
|
||||
| 파일 | 역할 |
|
||||
|---|---|
|
||||
| `backend/services/collector/static_html_adapter.py` | robots.txt 준수 fetch + JSON-LD/OG 추출. `_DENY_HOSTS` 로 플랫폼 차단 |
|
||||
| `backend/services/prompts/extract.py` | 추출 프롬프트·응답 스키마 (업종 스키마에서 자동 생성) |
|
||||
| `backend/services/grounding/extract.py` | **evidence 원문 대조 게이트** |
|
||||
| `backend/services/external/gemini_extract.py` | 엮는 자리 |
|
||||
| `backend/tests/test_extract_grounding.py` | 게이트 회귀 테스트 17건 |
|
||||
| `solution/backend/services/collector/static_html_adapter.py` | robots.txt 준수 fetch + JSON-LD/OG 추출. `_DENY_HOSTS` 로 플랫폼 차단 |
|
||||
| `solution/backend/services/prompts/extract.py` | 추출 프롬프트·응답 스키마 (업종 스키마에서 자동 생성) |
|
||||
| `solution/backend/services/grounding/extract.py` | **evidence 원문 대조 게이트** |
|
||||
| `solution/backend/services/external/gemini_extract.py` | 엮는 자리 |
|
||||
| `solution/backend/tests/test_extract_grounding.py` | 게이트 회귀 테스트 17건 |
|
||||
|
||||
비용: 호출당 약 $0.006 (gemini-3.7-flash, temperature 0.0).
|
||||
|
||||
|
||||
@ -18,6 +18,12 @@
|
||||
| 카카오맵 | **불가** | 상세는 SPA 셸(3.6KB), 내부 API 406 차단 |
|
||||
| **사장님이 확정한 자체 홈페이지** | **가능** | 사장님 동의 기반. → `StaticHtmlAdapter` 등록(2026-08-28) |
|
||||
|
||||
**추가 결론 (2026-08-31)** — TourAPI 어댑터를 붙여 같은 표본으로 대조한 결과
|
||||
([DATA_SOURCE_RESEARCH.md](DATA_SOURCE_RESEARCH.md) 8-2), 네이버 플레이스는 숙박 fact 2건 ·
|
||||
객실명만 주는데 TourAPI 는 fact 8종 · 객실 20개의 인원/요금/면적을 준다. **정보량도 적고
|
||||
출처도 못 밝힌다** — `naver_place` 어댑터를 유지할 근거가 사실상 사라졌다.
|
||||
아직 `COLLECT_ADAPTERS` 기본값에 남아 있으므로, 내리는 것이 남은 일이다.
|
||||
|
||||
부수 결론: **네이버·카카오 생태계는 AI 크롤러를 전면 차단**한다(플레이스·지도·블로그·예약·카카오맵 모두 `Disallow: /`). 즉 그 데이터를 긁어와도 AI 검색에는 원래 없던 정보라 GEO 이득이 없고, 출처를 밝힐 수 없어 `llms.txt`·`AnswerBlock`의 "출처와 검증 시각" 신호도 못 채운다. 공공데이터(TourAPI·LOCALDATA)가 그 자리를 대신한다.
|
||||
|
||||
| 항목 | 내용 (원안) |
|
||||
@ -82,11 +88,13 @@
|
||||
|
||||
## 3. 다음 단계에서 정해야 할 것 (법무 이슈 아님)
|
||||
|
||||
- **작업 큐 구현 방식.** 원본 보일러플레이트엔 APScheduler 크론만 있고 작업 큐가 없다.
|
||||
수집 + 비전 분석 + 빌드는 동기 요청으로 처리할 수 없다(몇 분). 후보: DB 테이블 기반 잡 큐(추가 인프라 0) / Celery + Redis / arq.
|
||||
→ **DB 잡 테이블 + 상태 폴링 API** 를 기본안으로 제안. 완료 시 알림톡.
|
||||
- ~~**작업 큐 구현 방식.**~~ → **결론남 (2026-08-27)**: LPS 의 `job` 큐를 그대로 이식했다.
|
||||
DB 잡 테이블 + 상태 폴링 API. 근거와 세부는 아래 5-2.
|
||||
- ~~**지역 정보 캐시 TTL.**~~ → **날씨는 결론남 (1시간, `local_content_service.get_weather`)**.
|
||||
상류가 죽으면 만료된 값을 `stale` 표시로 내보낸다 — 빈 값을 내보내지 않는다(절대규칙 9).
|
||||
**축제·관광지는 아직 TTL 이 없다** — 지금은 `sync-festivals` 를 사람이 눌러야 돈다.
|
||||
- **TourAPI areaCode ↔ 카카오 행정구역 코드 매핑 테이블.** 두 체계가 다르므로 매핑을 데이터로 관리할지 코드로 박을지.
|
||||
- **지역 정보 캐시 TTL.** 행정구역 코드 단위 캐싱은 확정. 날씨/축제/관광지 각각의 갱신 주기만 정하면 된다.
|
||||
(카카오 키가 아직 미발급이라 실제로 부딪히지 않았다)
|
||||
|
||||
---
|
||||
|
||||
@ -108,12 +116,12 @@
|
||||
| `server_default` | 신규 도메인 테이블에만 추가 | ORM `default=` 는 Python 쪽이라 raw INSERT 에 안 먹는다. `create_all`(테스트 DB)과 `init.sql`(실 DB)이 갈라져서 실제로 버그가 났다. **`companies`/`users` 는 원본 그대로 두었다** |
|
||||
| ORM ↔ init.sql 정합성 | `tests/test_schema_ddl.py` 가 파일을 파싱해 대조 | 스키마 정의가 두 곳에 있는 구조(원본 컨벤션)라, 드리프트를 테스트로 막는다 |
|
||||
| 외부 API 키 주입 | 레포 최상위 `.env` + `python-dotenv` | 우선순위 = 실제 환경변수 > `.env` > toml. **`APP_ENV=test` 면 `.env` 를 읽지 않는다** — 실키가 새면 테스트가 외부 API 를 때리고 요금이 나간다 |
|
||||
| 키 출처 | Perplexity·Gemini 는 `o2o-infinith-backend/.env` 값 재사용. 카카오·TourAPI 는 **미발급** | 사내 어디에도 카카오/TourAPI 키가 없다. 발급 후 `.env` 에 채워야 6번 단계가 돈다 |
|
||||
| 키 출처 | Perplexity·Gemini 는 `o2o-infinith-backend/.env` 값 재사용. **TourAPI 는 2026-08-31 활용신청 승인**, 카카오는 여전히 **미발급** | 카카오 키가 없으면 `kakao` 어댑터만 비활성이고 주변 정보 블록이 비어 뜬다 |
|
||||
|
||||
### 아직 테이블이 없는 것
|
||||
|
||||
- **`report` 스키마** — 노출 리포트·유입 통계(GA4 Data API, Search Console API). 작업 순서 6번 이후.
|
||||
- **작업 큐** — 아래 3번의 미결 사항. 수집·비전분석·빌드를 담을 잡 테이블이 아직 없다.
|
||||
- ~~**작업 큐**~~ → `job.jobs` 로 생겼다 (2026-08-27, 5-2). 17번째 테이블이다.
|
||||
- **TourAPI areaCode ↔ 카카오 행정구역 코드 매핑** — 테이블 대신 `common/category_schema` 와 같은 리소스 JSON 으로 두는 것을 제안. 3번 참고.
|
||||
|
||||
---
|
||||
@ -169,8 +177,10 @@
|
||||
|
||||
### 5-3. 아직 안 한 것
|
||||
|
||||
- **`검색어 → URL 발견 → 크롤링 → 에셋` 체인은 아직 안 돈다.** 작업순서 4번(collector).
|
||||
지금은 URL 을 **받아서** 저장하는 그릇만 있다(Perplexity 자동 호출 없음).
|
||||
- 사진(`place.media`)은 테이블만 있고 업로드·저장 경로가 없다. 이미지 재게시 권리(1-2)가 미결이라
|
||||
Azure Blob 클라이언트를 일부러 아직 이식하지 않았다.
|
||||
- ~~**`검색어 → URL 발견 → 크롤링 → 에셋` 체인**~~ → **돈다 (2026-08-28~31)**.
|
||||
Perplexity 채널 발견 + `tour_api`·`naver_place`·`static_html` 어댑터가 붙었다
|
||||
([DATA_SOURCE_RESEARCH.md](DATA_SOURCE_RESEARCH.md) 8-1·8-2).
|
||||
- 사진(`place.media`)은 **조회는 열렸고**(`/v1/place/{id}/media`, 빌더가 `useListMedia` 로 읽는다)
|
||||
**업로드·저장 경로가 없다.** 이미지 재게시 권리(1-2)가 미결이라 Azure Blob 클라이언트를
|
||||
일부러 아직 이식하지 않았다.
|
||||
- 카카오 REST API 키 / TourAPI 키가 사내 어디에도 없다. 발급해서 `.env` 에 채워야 실제 연동이 돈다.
|
||||
|
||||
@ -72,7 +72,7 @@ site-out/
|
||||
|
||||
프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 그래서 로컬 out/ 은 재시작만
|
||||
하면 정합이 맞는다. 하지만 Azure 는 발행 잡이 도는 사이트 하나씩만 올린다 — 그 짝을 맞추는 게
|
||||
`backend/scripts/republish_all.py` 다.
|
||||
`solution/backend/scripts/republish_all.py` 다.
|
||||
|
||||
**규칙: `solution/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
|
||||
|
||||
@ -91,8 +91,10 @@ docker compose exec worker python scripts/republish_all.py
|
||||
|
||||
| 어디 | 무엇 | 기본값 |
|
||||
|---|---|---|
|
||||
| 백엔드 | `SITE_PUBLIC_HOST` (`site_payload.py`) | `w4ai.o2o.kr` |
|
||||
| 프론트(admin) | `VITE_PUBLISH_HOST` | 없으면 현재 브라우저 호스트 |
|
||||
| 백엔드 | `SITE_PUBLIC_HOST` (`site_payload.py` 의 `DEFAULT_HOST`) | `w4ai.o2o.kr` |
|
||||
| 프론트(빌더·`web` 컨테이너) | `VITE_PUBLISH_HOST` | compose 가 루트의 `SITE_PUBLIC_HOST` 를 흘려보낸다 |
|
||||
|
||||
★ 프론트 `.env` 에 따로 적지 않는다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
|
||||
|
||||
이 값이 canonical·og:url·sitemap·IndexNow 통보에 전부 들어간다. 다른 주소에 올릴 거면
|
||||
`SITE_PUBLIC_HOST` 를 먼저 덮는다. 안 고치면 발행은 성공하는데 사이트맵과 색인 통보가
|
||||
@ -168,7 +170,7 @@ docker compose exec worker python scripts/republish_all.py # 전체
|
||||
### 4-2단계 — 도메인 · TLS · 색인
|
||||
1. DNS 를 Blob 정적 웹사이트(또는 앞단 CDN)로 연결
|
||||
2. HTTPS 확인 — 커스텀 도메인 + TLS 는 Blob 단독으로는 안 되고 CDN/Front Door 가 필요하다
|
||||
3. `DEFAULT_HOST` 가 실제 도메인과 같은지 재확인 → 다르면 고치고 **전체 재발행**
|
||||
3. `SITE_PUBLIC_HOST` 가 실제 도메인과 같은지 재확인 → 다르면 고치고 **전체 재발행**
|
||||
4. 그 다음에야 `INDEXNOW_KEY` 를 채운다. 백엔드와 `o2o-web4ai-web` 이 **같은 값**이어야 한다
|
||||
(프리렌더가 루트에 `<key>.txt` 를 굽고 검색엔진이 대조한다 — 어긋나면 403)
|
||||
5. `https://<도메인>/<key>.txt` 와 `https://<도메인>/robots.txt` 가 열리는지 확인
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
| Site AEO A1~A9 | **부분 구현** — A3·A5·A6·A7 일부와 A8 중심 |
|
||||
| Brand AEO B1~B9 | **미구현** — 준비도 자체 점수만 있으며 실제 AI 응답 측정은 없음 |
|
||||
| 운영 콘솔 15개 화면 | **부분 구현** — 사업장·빌더·지역정보·SEO/AEO 준비도 중심 |
|
||||
| 계약 A~G / BFF | **미구현** — 단일 FastAPI API를 admin이 직접 호출 |
|
||||
| 계약 A~G / BFF | **미구현** — 화면이 FastAPI 를 직접 호출 (BFF 없음) |
|
||||
| 25테이블 append-only Fact Graph | **다른 모델로 구현** — 승인 후보/노출값 중심의 key-value fact 모델 |
|
||||
| 8개 스프린트 일정 | **현재 코드에 바로 적용 불가** — 이미 구현된 것과 방향 충돌 항목이 섞여 있어 재산정 필요 |
|
||||
|
||||
@ -41,11 +41,11 @@
|
||||
| 입력 방식 | 고객 원본 사이트를 depth 3·최대 200페이지 크롤 | 공식 API, 사용자 확정 URL, 정적 HTML; 플랫폼 우회 수집 금지 | 현재의 출처·동의·robots 원칙 유지. 대규모 원본 사이트 수집은 별도 제품 모드로 분리 |
|
||||
| 동적 크롤링 | 정적 우선, Playwright 폴백 | 봇 탐지 우회로 변질될 수 있어 HeadlessAdapter 미등록·금지 | 포괄적인 Playwright 폴백은 채택하지 않음. 소유권이 검증된 고객 도메인에만 허용할지 법무·보안 결정 후 제한적으로 검토 |
|
||||
| 발행 도메인 | 고객 도메인 서브패스 권장, 서브도메인 차선 | 기본 `w4ai.o2o.kr/s/<slug>`, custom domain 경로 미완성 | 설계서 방향이 검색 권위 측면에서 더 적합. 고객 도메인 연결·소유권 검증을 우선 과제로 추가 |
|
||||
| 백엔드 구조 | Site/Brand 엔진, BFF, 엔진별 DB 분리 | 단일 FastAPI + PostgreSQL + admin 직접 호출 | 파일럿 단계에서는 모듈 경계와 API 계약만 먼저 만들고, 물리 분리는 트래픽·팀 소유권 근거가 생긴 뒤 수행 |
|
||||
| 백엔드 구조 | Site/Brand 엔진, BFF, 엔진별 DB 분리 | 코드 한 벌 + 진입점 둘(:9800 사장님 / :9801 내부), 단일 PostgreSQL | 청중별 분리는 포트로 끝냈다. 엔진별 물리 분리는 트래픽·팀 소유권 근거가 생긴 뒤 수행 |
|
||||
| 작업 인프라 | Temporal, Redis, Celery 등 공통 인프라 | PostgreSQL 잡 큐 + lease + dead-letter | 현재 DB 큐 유지. 동일 책임의 인프라를 중복 도입하지 않음. 장기 워크플로 보상·분산 추적 요구가 확인될 때 Temporal 재평가 |
|
||||
| Fact Graph | 엔티티·predicate·snapshot, append-only | 업종 스키마 기반 key-value fact, 후보/노출/이력 상태 | 현재 모델은 발행 안전성에 적합. Brand 측정 재현성에 필요한 snapshot과 entity 관계만 점진적으로 확장 |
|
||||
| 점수 | Site AEO Score + 실제 4개 AI 엔진 기반 AVS | 내부 데이터 기반 SEO/AEO **준비도** 점수 | 이름과 의미를 분리 유지. 실제 측정 전 현재 점수를 AVS/가시성 점수라고 부르지 않음 |
|
||||
| 운영 사용자 | Reviewer/Owner 권한과 15개 통합 화면 | USER/OWNER/DEVELOPER, 사장님 빌더와 운영 화면이 한 앱 | 규제 기능 도입 전 Reviewer 역할·서버 계산 `allowed_actions` 추가. 사장님 앱과 내부 콘솔 분리 계획 수립 |
|
||||
| 운영 사용자 | Reviewer/Owner 권한과 15개 통합 화면 | USER/OWNER/DEVELOPER, 사장님 앱(:3000)과 내부 콘솔(:3002)이 갈려 있다 | 규제 기능 도입 전 Reviewer 역할·서버 계산 `allowed_actions` 추가 |
|
||||
| 이미지 | 원본 사진·영상이 EEAT 근거 | 이미지 호스팅을 현재 non-goal로 두고 외부 URL 사용 | 저작권 결론과 소유자 업로드 저장소가 먼저. 크롤 이미지 재게시를 전제로 개발하지 않음 |
|
||||
|
||||
---
|
||||
@ -84,8 +84,8 @@
|
||||
|
||||
### 3-3. 콘솔·계약·데이터
|
||||
|
||||
- 현재 admin에는 로그인, 사업장 목록/상세, 빌더, 지역 콘텐츠, SEO 진단 등이 있으나 설계서의 질문 빌더·규제 승인·AI 퍼포먼스·랭킹·인용출처·정기 리포트 화면은 없다.
|
||||
- 프론트는 단일 API를 직접 호출한다. 설계서의 BFF, 서비스 JWT 교환, 섹션별 부분 실패, 계약 A~G는 없다.
|
||||
- 내부 콘솔(`admin/frontend`)에는 사업장 목록/상세, 지역 콘텐츠, SEO 진단이 있고 빌더는 사장님 앱(`solution/frontend`)에 있다. 설계서의 질문 빌더·규제 승인·AI 퍼포먼스·랭킹·인용출처·정기 리포트 화면은 없다.
|
||||
- 화면이 API 를 직접 호출한다. 설계서의 BFF, 서비스 JWT 교환, 섹션별 부분 실패, 계약 A~G는 없다.
|
||||
- DB에는 현재 17개 ORM 모델이 있으며 설계서의 `document`, `predicate_def`, `entity`, `fact_snapshot`, `compliance_rule`, `review`, `publication_question`, `index_state`, `regeneration_request`, `event_outbox`, `audit_log` 등에 해당하는 완성 모델은 없다.
|
||||
- 현재 fact는 수정 잠금과 후보 이력을 보존하지만, 설계서가 요구하는 전체 append-only 불변식·스냅샷 재현성 모델과 같지는 않다.
|
||||
|
||||
@ -118,7 +118,7 @@
|
||||
3. A7 SimHash 중복도 검사 및 fact 역참조 리포트
|
||||
4. A9 원본 변경 감지, fact 만료/검토, AI 크롤러 방문 로그
|
||||
5. 고객 도메인 연결, TLS/DNS 운영 절차
|
||||
6. 관리자/사장님 앱 경계 정리와 서버 계산 `allowed_actions`
|
||||
6. 서버 계산 `allowed_actions` (앱 경계 분리는 2026-08-31 완료)
|
||||
|
||||
완료 기준은 “페이지가 만들어진다”가 아니라, **소유권이 확인된 원본에서 출처가 남는 fact를 만들고 두 게이트를 통과해 고객 도메인에 발행되며, 변경과 재방문을 관측할 수 있다**는 것이다.
|
||||
|
||||
|
||||
@ -48,10 +48,10 @@
|
||||
| 원하는 것 | 몇 번 눌러서 끝나기 | 무엇이 왜 막혔는지 보이기 | 정확한 사실, 즉시 |
|
||||
| 로그인 | 최소 (막히면 만들어 보지도 못한다) | 엄격 | 없음 |
|
||||
| 화면 성격 | 위저드 + 에디터 | 대시보드 + 목록 | 정적 문서 |
|
||||
| 현재 코드 | `admin/` `/builder` | `admin/` `/places`, `/local-content` | `site/` |
|
||||
| 현재 코드 | `solution/frontend` `/builder` | `admin/frontend` `/places`, `/local-content` | `solution/site` |
|
||||
|
||||
★ **앞의 둘이 지금 한 앱에 섞여 있다.** 왜 나눠야 하는지와 나누는 방법은
|
||||
[ARCHITECTURE.md 4절](ARCHITECTURE.md#4-앱-경계--지금-구조와-나눌-지점).
|
||||
★ **앞의 둘은 2026-08-31 에 두 앱으로 갈랐다** — 한 앱이면 내부 화면 코드가 사장님 번들에
|
||||
그대로 실려 나가기 때문이다. 근거와 경계는 [ARCHITECTURE.md 4절](ARCHITECTURE.md).
|
||||
|
||||
권한 코드는 `common/enums.py UserRole`: `1 USER`(사장님) / `2 OWNER`(고객사 최상위) /
|
||||
`3 DEVELOPER`(내부 운영 — **고객사에 존재를 노출하지 않는다**).
|
||||
|
||||
@ -1,153 +0,0 @@
|
||||
# solution — 사장님 앱 (backend · front · site)
|
||||
|
||||
소상공인 홈페이지 자동 생성 솔루션의 프론트엔드.
|
||||
`o2o-negosium/negodata/front` 보일러플레이트를 이식했다 — 빌드 도구·구조·프로토콜 규약은 원본과 같다.
|
||||
|
||||
**목표는 예쁜 사이트가 아니라 AI 검색이 이 가게를 공식 홈페이지 기준으로 설명하게 만드는 것**이다
|
||||
(백엔드 README 와 같은 문장). 그래서 두 앱의 요구사항이 정반대다.
|
||||
|
||||
| | `front/` (빌더) | `site/` (발행 사이트) |
|
||||
|---|---|---|
|
||||
| 사용자 | 사장님 · 운영자 | 손님 · **검색/AI 크롤러** |
|
||||
| 렌더링 | CSR SPA | **SSG (정적 HTML)** |
|
||||
| 색인 | `noindex` | 색인·인용되라고 존재 |
|
||||
| 런타임 | Vite dev / 정적 호스팅 | **서버 없음.** 파일만 |
|
||||
| 데이터 | 편집 중 상태(미확인 값 포함) | `SitePayload` — **확인된 값만** |
|
||||
|
||||
한 프로젝트에 두 앱을 두되 빌드를 나눈 이유가 이 표다. 보일러플레이트는 순수 CSR 이라
|
||||
발행 사이트에 그대로 쓰면 크롤러가 `<div id="root"></div>` 만 읽고 떠난다.
|
||||
|
||||
```
|
||||
solution/
|
||||
├── backend/ FastAPI + 워커. payload JSON 만 떨어뜨린다
|
||||
├── shared/ front·site 가 공유 — 도메인 enum · SitePayload 계약 · 디자인 토큰 · fact 필터
|
||||
├── front/ 빌더 (위저드 4단계 + 실시간 에디터 + 발행 게이트)
|
||||
└── site/ 발행 사이트 렌더러 + 프리렌더 스크립트
|
||||
|
||||
내부 운영 화면은 여기 없다 — 최상단 `admin/` 이다(ARCHITECTURE.md 4절).
|
||||
```
|
||||
|
||||
## 실행
|
||||
|
||||
### Docker Compose (기본 실행 방식)
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
- 발행 사이트: `http://localhost:3000/s/<slug>`
|
||||
- API/Swagger: `http://localhost:9800/docs`
|
||||
- 정적 사이트 저장 위치: `solution/site/out/s/<slug>/`
|
||||
- 프리렌더 입력 payload: `solution/site/payloads/<slug>.json`
|
||||
- 컨테이너 내부 정적 서버는 3001을 사용하지만, Docker가 호스트 3000으로만 공개한다.
|
||||
|
||||
예: `http://localhost:3000/s/grazz`
|
||||
|
||||
```bash
|
||||
npm install # 워크스페이스 루트에서 한 번
|
||||
|
||||
npm run dev # 빌더 → http://localhost:3000
|
||||
npm run dev:site # 발행 사이트 → http://localhost:3001 (데모 payload 로 CSR)
|
||||
|
||||
npm run build # 전체 타입체크 + 린트 + 빌드
|
||||
npm run prerender # ★ 정적 사이트 굽기 → site/out/<slug>/
|
||||
```
|
||||
|
||||
백엔드는 기본 `http://localhost:9800` 으로 본다. 바꾸려면 `admin/.env` 에 `VITE_API_BASE_URL`.
|
||||
`cp admin/.env.example admin/.env` 로 시작한다.
|
||||
|
||||
## 발행 파이프라인
|
||||
|
||||
```
|
||||
관리자 편집 → [발행 게이트] → SitePayload JSON → prerender → 정적 파일
|
||||
↓ 막히면 발행 불가
|
||||
미검증 fact / 필수 항목 누락 / 고유 콘텐츠 0건
|
||||
```
|
||||
|
||||
```bash
|
||||
# 데모 payload 로
|
||||
npm run prerender
|
||||
|
||||
# 실제 payload 로 (백엔드 BUILD 잡이 부르는 자리)
|
||||
npm run prerender -- --payload=./payloads --out=/var/www
|
||||
```
|
||||
|
||||
사이트 하나당 나오는 것:
|
||||
|
||||
```
|
||||
out/<slug>/
|
||||
├── index.html 홈 (JSON-LD 4종 + 본문 전체가 HTML 에)
|
||||
├── rooms/index.html 하위 단위 목록 ← 업종별 경로(rooms/menu/programs)
|
||||
├── rooms/<unit>/index.html 단위 상세
|
||||
├── guide|location|faq/index.html
|
||||
├── sitemap.xml
|
||||
├── robots.txt ★ AI 크롤러 명시 허용
|
||||
├── llms.txt ★ LLM 이 읽을 사실 목록
|
||||
└── assets/ 하이드레이션 번들
|
||||
```
|
||||
|
||||
## SEO / AEO 가 어디에 박혀 있나
|
||||
|
||||
| 무엇 | 어디 | 왜 |
|
||||
|---|---|---|
|
||||
| 정적 HTML | `site/scripts/prerender.ts` | JS 를 실행 안 하는 AI 크롤러가 본문을 그대로 읽는다 |
|
||||
| 구조화 데이터 | `site/src/seo/jsonld.ts` | 업종별 Schema.org 타입 + FAQPage + BreadcrumbList + WebPage |
|
||||
| meta · OG · geo | `site/src/seo/meta.ts` `head.ts` | description 을 **확인된 fact 로 조립**한다(지어내지 않는다) |
|
||||
| `llms.txt` | `site/src/seo/llms.ts` | 사실만. 형용사 금지. 모르는 건 "정보 없음"이라고 적는다 |
|
||||
| `robots.txt` | `site/src/seo/robots.ts` | GPTBot · ClaudeBot · PerplexityBot 등 **명시 허용** |
|
||||
| 핵심 정보 요약 | `site/src/sections/AnswerBlock.tsx` | AI 가 답으로 뽑아 가는 단정문을 상단에 고정 배치 |
|
||||
| FAQ | `site/src/sections/FaqSection.tsx` | `<details>` — 접혀 있어도 크롤러가 읽는다 |
|
||||
| 발행 게이트 | `admin/src/features/publish/publishGate.ts` | 백엔드 `PublishRejectReason` 과 1:1 |
|
||||
|
||||
### 절대규칙 1 이 지켜지는 지점
|
||||
|
||||
> 확인되지 않은 fact 는 사이트에 나가지 않는다.
|
||||
|
||||
한 곳에서만 거른다 — `shared/src/lib/facts.ts`.
|
||||
|
||||
- `selectPublishable()` — `VERIFIED` · `CORRECTED` 만 통과
|
||||
- `sanitizePayloadForPublish()` — **프리렌더가 payload 를 여기 통과시킨 뒤 렌더와 임베드 양쪽에 쓴다**
|
||||
|
||||
두 번째가 중요하다. 정적 HTML 은 하이드레이션용으로 payload 를 통째로 심는데,
|
||||
화면과 JSON-LD 만 걸러 두면 그 블롭에 미검증 값이 남아 원본 HTML 을 읽는 AI 가 그걸 읽는다.
|
||||
(실제로 한 번 그렇게 샜고, 그래서 이 함수가 생겼다.)
|
||||
|
||||
## 백엔드 연결
|
||||
|
||||
API 클라이언트는 **전부 orval 생성물**이다(React Query 훅). 화면은 `@/api` 하나만 import 한다.
|
||||
자세한 규약은 [admin/src/api/README.md](admin/src/api/README.md).
|
||||
|
||||
```bash
|
||||
npm run orval -w admin # 백엔드가 떠 있을 때
|
||||
|
||||
cd ../backend && python scripts/export_openapi.py # 서버 없이 — 스펙을 먼저 뽑고
|
||||
cd .. && ORVAL_INPUT=solution/backend/openapi.json npm run orval
|
||||
```
|
||||
|
||||
열려 있는 도메인은 `auth` / `place` / `fact` / `job` / `site` 전부다.
|
||||
|
||||
| 화면 | 부르는 것 |
|
||||
|---|---|
|
||||
| 사업장 목록·상세 | `useListPlaces` `useGetPlace` `useListFacts` `useListLinks` `useTransitionFact` `useConfirmLink` |
|
||||
| 위저드 2단계(수집) | `POST /place/{id}/collect` → 잡 폴링 — `useGatherSimulation.ts` |
|
||||
| 위저드 4단계(생성) | `POST /place/{id}/copy` → 잡 폴링 — `Step4Generating.tsx` |
|
||||
| 발행 | `POST /place/{id}/site/build {publish:true}` → 잡 폴링 — `features/publish/usePublishSite.ts` |
|
||||
|
||||
**발행 = 빌드다.** 백엔드에 발행 엔드포인트가 따로 없는 것이 맞다 — 발행 검수 게이트가
|
||||
빌드 잡 안에 있어서(`services/build_service` → `publish_gate`) 게이트를 우회하는 경로가 없다.
|
||||
그래서 잡이 DONE 이어도 발행됐다는 뜻이 아니다. `job.result.gate.passed` 를 봐야 한다.
|
||||
|
||||
★ `placeId` 가 없는 데모 경로(`/builder`)는 이 호출을 하나도 하지 않는다 —
|
||||
로그인 없이 도는 화면이라 예전의 타이머 시뮬레이션으로 떨어진다. 실사업장은 `/builder?placeId=<uuid>`.
|
||||
|
||||
CORS 는 백엔드 `config.local.toml` 의 `client_url` 이 정한다(쉼표로 여러 오리진).
|
||||
vite 가 3000 을 못 잡고 3001·3002 로 옮겨 뜨면 거기서 막히므로 개발 포트 대역을 함께 적어 둔다.
|
||||
|
||||
## 아직 안 한 것
|
||||
|
||||
- **폰트 self-host** — `*/public/fonts/PretendardVariable.woff2` 가 없다. 지금은 Noto Sans KR 로 폴백된다
|
||||
- **이미지 최적화** — 원본 URL 을 그대로 쓴다. `srcset`/WebP 변환은 media 파이프라인이 붙은 뒤
|
||||
- **admin 번들 분할** — 626KB(gzip 185KB). 라우트 단위 `lazy()` 로 나눌 수 있다
|
||||
- **사진(media) 연동** — 백엔드에 media 조회 엔드포인트가 없어 빌더의 사진 탭은 실데이터가 비어 있다
|
||||
(`stores/builder.ts` 의 `photos: []`). VISION 잡이 붙인 분류·alt 를 읽을 창구가 열리면 채운다
|
||||
- **발행 주소** — `sites.domain` 을 채우는 경로가 아직 없다. 그때까지는 상호 슬러그로 주소를 만든다
|
||||
@ -9,16 +9,21 @@
|
||||
|
||||
**수집 → 생성 → 빌드 → 발행 파이프라인이 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` | 완료 | 수집 파이프라인 (어댑터 패턴 — Phase 1 은 MockAdapter 만) |
|
||||
| `collector` | 완료 | 수집 파이프라인. 어댑터 `tour_api` · `naver_place` · `static_html` · `mock` — `COLLECT_ADAPTERS` 로 개별 on/off |
|
||||
| `generator` | 완료 | Gemini 호출 — 사진 분류(vision), 소개문·FAQ 작성(copy) |
|
||||
| `local` | 스키마 완료 / API 미착수 | 지역 정보 (날씨·축제·관광지·맛집) + 행정구역 코드 단위 캐싱 |
|
||||
| `local` | 완료 | 지역 정보 (날씨·축제·관광지·맛집) + 행정구역 코드 단위 캐싱 |
|
||||
| `sites` | 완료 | 사이트 상태, 정적 빌드, 발행 검수 게이트, 발행 상태 전이 |
|
||||
| `media` | 스키마 완료 / 조회 API 미착수 | 사진·alt. 수집·비전이 채우지만 아직 읽을 창구가 없다 |
|
||||
| `media` | 완료 | 사진·alt 조회. 수집·비전이 채우고 빌더가 `useListMedia` 로 읽는다 |
|
||||
| `reports` | 미착수 | 노출 리포트, 유입 통계 |
|
||||
|
||||
> OpenAPI 스펙은 `python scripts/export_openapi.py` 로 서버 없이 뽑을 수 있다
|
||||
@ -74,7 +79,7 @@ UNVERIFIED ──┬─> PENDING_OWNER ──┬─> VERIFIED ──┬─> CORR
|
||||
- **활성 fact 는 (사업장, 단위, key) 당 1건** — DB 부분 유니크 인덱스로 강제.
|
||||
`REJECTED`·`EXPIRED` 는 이력이라 유니크에서 빠진다
|
||||
|
||||
미결 사항(크롤링 법적 검토 · 이미지 재게시 권리 · 관리자 수정 범위 · 해지 정책)은 [../docs/DECISIONS.md](../docs/DECISIONS.md).
|
||||
미결 사항(크롤링 법적 검토 · 이미지 재게시 권리 · 관리자 수정 범위 · 해지 정책)은 [../../docs/DECISIONS.md](../../docs/DECISIONS.md).
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
@ -82,10 +87,11 @@ UNVERIFIED ──┬─> PENDING_OWNER ──┬─> VERIFIED ──┬─> CORR
|
||||
o2o-web4ai/
|
||||
├── .env.example # 외부 API 키·DB 접속 주입 템플릿 (cp .env.example .env)
|
||||
├── postgres-init/
|
||||
│ ├── init-data/init.sql # 스키마 DDL 전부 (재실행 안전)
|
||||
│ └── alters/ # 컬럼 추가/타입 변경 누적 (YYYY-MM-DD-<주제>.sql)
|
||||
└── backend/
|
||||
├── web_main.py # 엔트리포인트 (uvicorn)
|
||||
│ └── 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/
|
||||
@ -94,14 +100,14 @@ o2o-web4ai/
|
||||
│ ├── models/gmodel.py # 프로토콜 베이스 (WebPacketProtocol 등)
|
||||
│ └── database/
|
||||
│ ├── db_session_manager.py# ★ DB Read/Write + 람다 실행 핵심
|
||||
│ └── model/models.py # ORM 모델 (16 테이블)
|
||||
│ └── 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
|
||||
└── validator/dependencies.py # ★ JWT 발급·검증, 해시(bcrypt), RemoveNoneResponse, RequireOwner/RequireDeveloper
|
||||
```
|
||||
|
||||
## 핵심 패턴
|
||||
@ -130,6 +136,8 @@ o2o-web4ai/
|
||||
| `/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` | 사이트 상태(재빌드 필요 여부) · **사이트 주소 확인/예약** · **정적 빌드/발행** · 버전 목록 · 발행 기록 · 발행 상태 전이 |
|
||||
|
||||
@ -163,7 +171,7 @@ POST /v1/place/{id}/site/slug {slug} → sites.domain 에 저장(사
|
||||
- ★ **이미 발행된 사이트의 주소는 바꾸지 않는다**(`SITE_SLUG_LOCKED`). 색인된 주소가 바뀌면
|
||||
AI 검색이 잡아 둔 페이지가 404 가 되고 그 자리를 다시 OTA 가 가져간다 — '해지는 상태 전이지 삭제가 아니다' 와 같은 이유다.
|
||||
|
||||
### DB 스키마 (16 테이블)
|
||||
### DB 스키마 (17 테이블)
|
||||
|
||||
| schema | 테이블 |
|
||||
|---|---|
|
||||
@ -172,6 +180,7 @@ POST /v1/place/{id}/site/slug {slug} → sites.domain 에 저장(사
|
||||
| `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개가 컬럼으로 박혀 있다.
|
||||
|
||||
@ -187,7 +196,7 @@ POST /v1/place/{id}/site/slug {slug} → sites.domain 에 저장(사
|
||||
|
||||
| 무엇 | 어디 | 커밋 |
|
||||
|---|---|---|
|
||||
| DB 접속 · JWT · 포트 | `backend/config/config.local.toml` | ✗ (`*.toml` ignore) |
|
||||
| DB 접속 · JWT · 포트 | `solution/backend/config/config.local.toml` | ✗ (`*.toml` ignore) |
|
||||
| 외부 API 키 | 레포 최상위 `.env` | ✗ |
|
||||
| 템플릿 | `config/config.local.toml.example` · `.env.example` | ✓ |
|
||||
|
||||
@ -199,17 +208,17 @@ POST /v1/place/{id}/site/slug {slug} → sites.domain 에 저장(사
|
||||
|
||||
DB 준비(최초 1회) — 로컬 PostgreSQL 에 스키마를 적용한다.
|
||||
```bash
|
||||
psql -h 127.0.0.1 -p 5432 -U postgres -f ../postgres-init/init-data/init.sql
|
||||
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
|
||||
# psql -h 127.0.0.1 -U postgres -d postgres -v ON_ERROR_STOP=1 < ../../postgres-init/init-data/init.sql
|
||||
```
|
||||
|
||||
서버 실행.
|
||||
```bash
|
||||
cd ..
|
||||
cd ../.. # 레포 루트
|
||||
cp .env.example .env # 최초 1회, 외부 API 키 채우기
|
||||
cd backend
|
||||
cd solution/backend
|
||||
cp config/config.local.toml.example config/config.local.toml # 최초 1회, DB·JWT 채우기
|
||||
python3 -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
@ -224,7 +233,7 @@ python web_main.py # APP_ENV 기본 local → http://localh
|
||||
test DB(`web4ai_test_db`)는 알아서 만들어졌다 지워지므로 **수동 세팅이 필요 없다.**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cd solution/backend
|
||||
source .venv/bin/activate
|
||||
pip install pytest pytest-asyncio # 테스트 도구(requirements 에 없음)
|
||||
|
||||
@ -235,7 +244,7 @@ python -m pytest tests/test_auth.py # 파일 하나만
|
||||
venv 를 활성화하지 않으면 `.venv/bin/python -m pytest` 로 직접 지정한다. 정상이면 마지막 줄에 `NN passed`.
|
||||
|
||||
동작 방식 (전부 [conftest.py](conftest.py) 가 자동 처리):
|
||||
- `APP_ENV` 를 `test` 로 자동 설정 → [config.test.toml](config/config.test.toml) 의 **`web4ai_test_db`** 사용(dev DB 와 완전 분리).
|
||||
- `APP_ENV` 를 `test` 로 자동 설정 → `config/config.test.toml`([예제](config/config.test.toml.example)) 의 **`web4ai_test_db`** 사용(dev DB 와 완전 분리).
|
||||
- **세션 시작 시 test DB 를 새로 만들고(CREATE), 끝나면 내린다(DROP).** 매번 현재 모델로 새로 빌드돼 스키마가 낡을 일이 없다.
|
||||
- 테이블은 `create_all` 로 자동 생성, 매 테스트 전 `TRUNCATE` 로 비워 격리.
|
||||
- 안전가드: 이름에 `test` 없는 DB 는 만들지도 지우지도 않는다(실 DB 보호).
|
||||
|
||||
@ -1,247 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>핑크비치펜션 - 숙박</title>
|
||||
<meta name="description" content="하조대 해변에서 도보 3분 거리에 있는 2개 동 규모의 펜션입니다. 모든 객실에서 바다가 보이며, 취사가 가능하고 야외 바비큐 공간을 갖추고 있습니다.">
|
||||
<style>
|
||||
:root{--fg:#1a1a1a;--muted:#666;--line:#e5e5e5;--bg:#fff}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;padding:0 16px 48px;font:16px/1.7 system-ui,-apple-system,"Apple SD Gothic Neo","Malgun Gothic",sans-serif;color:var(--fg);background:var(--bg)}
|
||||
main{max-width:760px;margin:0 auto}
|
||||
header{padding:32px 0 16px;border-bottom:2px solid var(--fg)}
|
||||
h1{margin:0 0 4px;font-size:1.75rem;line-height:1.3}
|
||||
.category{margin:0;color:var(--muted);font-size:.9rem}
|
||||
section{padding:24px 0;border-bottom:1px solid var(--line)}
|
||||
section:last-child{border-bottom:0}
|
||||
h2{margin:0 0 12px;font-size:1.1rem}
|
||||
h3{margin:0 0 8px;font-size:1rem}
|
||||
dl{display:grid;grid-template-columns:minmax(7rem,auto) 1fr;gap:6px 16px;margin:0}
|
||||
dt{color:var(--muted)}
|
||||
dd{margin:0}
|
||||
article{padding:12px 0;border-top:1px dashed var(--line)}
|
||||
article:first-of-type{border-top:0}
|
||||
figure{margin:0}
|
||||
.photos{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px}
|
||||
img{width:100%;height:auto;display:block;border-radius:6px}
|
||||
figcaption{margin-top:4px;color:var(--muted);font-size:.85rem}
|
||||
.faq details{border-top:1px dashed var(--line);padding:10px 0}
|
||||
.faq details:first-of-type{border-top:0}
|
||||
.faq summary{cursor:pointer;font-weight:600;list-style:none;display:flex;justify-content:space-between;gap:12px}
|
||||
.faq summary::-webkit-details-marker{display:none}
|
||||
.faq summary::after{content:"−";color:var(--muted);flex:none}
|
||||
.faq details:not([open]) summary::after{content:"+"}
|
||||
.faq p{margin:8px 0 0;color:var(--muted)}
|
||||
a{color:inherit}
|
||||
|
||||
/* 앵커 네비 — 긴 페이지에서 원하는 정보로 바로 간다 */
|
||||
html{scroll-behavior:smooth}
|
||||
nav.toc{position:sticky;top:0;z-index:5;display:flex;gap:4px;overflow-x:auto;
|
||||
padding:10px 0;margin-bottom:-1px;background:var(--bg);border-bottom:1px solid var(--line);
|
||||
-webkit-overflow-scrolling:touch;scrollbar-width:none}
|
||||
nav.toc::-webkit-scrollbar{display:none}
|
||||
nav.toc a{flex:none;padding:6px 12px;border:1px solid var(--line);border-radius:999px;
|
||||
font-size:.85rem;text-decoration:none;color:var(--muted);white-space:nowrap}
|
||||
nav.toc a:hover,nav.toc a:focus-visible{border-color:var(--fg);color:var(--fg)}
|
||||
section{scroll-margin-top:56px}
|
||||
|
||||
/* 사진 — 클릭하면 크게. JS 가 없으면 그냥 그리드로 다 보인다 */
|
||||
.photos button{all:unset;display:block;cursor:zoom-in;width:100%}
|
||||
.photos button:focus-visible img{outline:2px solid var(--fg);outline-offset:2px}
|
||||
.lightbox{position:fixed;inset:0;z-index:50;display:none;place-items:center;
|
||||
background:rgba(0,0,0,.92);padding:16px}
|
||||
.lightbox[open],.lightbox.on{display:grid}
|
||||
.lightbox img{max-width:100%;max-height:78vh;width:auto;border-radius:4px}
|
||||
.lightbox figcaption{margin-top:12px;color:#eee;text-align:center;font-size:.9rem}
|
||||
.lightbox .nav{position:absolute;top:50%;transform:translateY(-50%);
|
||||
background:rgba(255,255,255,.15);color:#fff;border:0;border-radius:999px;
|
||||
width:44px;height:44px;font-size:1.4rem;cursor:pointer}
|
||||
.lightbox .prev{left:12px}.lightbox .next{right:12px}
|
||||
.lightbox .close{position:absolute;top:12px;right:12px;background:none;border:0;
|
||||
color:#fff;font-size:1.8rem;line-height:1;cursor:pointer;padding:8px}
|
||||
|
||||
/* 하단 고정 연락 바 — 모바일에서 전화가 제일 중요한 행동이다 */
|
||||
.cta{position:fixed;left:0;right:0;bottom:0;z-index:10;display:flex;gap:8px;
|
||||
padding:10px 16px calc(10px + env(safe-area-inset-bottom));
|
||||
background:var(--bg);border-top:1px solid var(--line)}
|
||||
.cta a,.cta button{flex:1;padding:12px;border-radius:8px;border:1px solid var(--fg);
|
||||
text-align:center;text-decoration:none;font-weight:600;font-size:.95rem;
|
||||
background:var(--bg);color:var(--fg);cursor:pointer;font-family:inherit}
|
||||
.cta a.primary{background:var(--fg);color:var(--bg)}
|
||||
body:has(.cta){padding-bottom:96px}
|
||||
@media(min-width:761px){.cta{position:static;max-width:760px;margin:24px auto 0;border:0;padding:0}
|
||||
body:has(.cta){padding-bottom:48px}}
|
||||
</style>
|
||||
<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"LodgingBusiness","name":"핑크비치펜션","address":{"@type":"PostalAddress","streetAddress":"강원특별자치도 양양군 현북면 하조대3길 11","addressCountry":"KR"},"telephone":"033-672-0000","geo":{"@type":"GeoCoordinates","latitude":38.0219217,"longitude":128.7221449},"image":["https://picsum.photos/seed/pension-out/1200/800","https://picsum.photos/seed/pension-bed/1200/800","https://picsum.photos/seed/pension-liv/1200/800","https://picsum.photos/seed/pension-bbq/1200/800","https://picsum.photos/seed/pension-duo/1200/800"],"description":"하조대 해변에서 도보 3분 거리에 있는 2개 동 규모의 펜션입니다. 모든 객실에서 바다가 보이며, 취사가 가능하고 야외 바비큐 공간을 갖추고 있습니다.","checkinTime":"15:00","checkoutTime":"11:00","petsAllowed":false,"amenityFeature":[{"@type":"LocationFeatureSpecification","name":"와이파이","value":true},{"@type":"LocationFeatureSpecification","name":"주차 가능","value":true},{"@type":"LocationFeatureSpecification","name":"조식 제공","value":false},{"@type":"LocationFeatureSpecification","name":"바비큐 이용","value":true}],"makesOffer":[{"@type":"Offer","name":"A동 스탠다드","price":150000,"priceCurrency":"KRW"},{"@type":"Offer","name":"B동 복층","price":220000,"priceCurrency":"KRW"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"체크인·체크아웃은 몇 시인가요?","acceptedAnswer":{"@type":"Answer","text":"체크인은 15:00, 체크아웃은 11:00 입니다."}},{"@type":"Question","name":"반려동물과 함께 갈 수 있나요?","acceptedAnswer":{"@type":"Answer","text":"반려동물 동반은 불가합니다."}},{"@type":"Question","name":"객실에서 취사가 가능한가요?","acceptedAnswer":{"@type":"Answer","text":"취사가 가능하며 모든 객실에 주방이 있습니다."}},{"@type":"Question","name":"바비큐를 이용하려면 얼마인가요?","acceptedAnswer":{"@type":"Answer","text":"바비큐 이용료는 20,000원입니다."}},{"@type":"Question","name":"주차는 가능한가요?","acceptedAnswer":{"@type":"Answer","text":"주차가 가능하며 6대까지 주차할 수 있습니다."}}]}]}</script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<nav class="toc"><a href="#intro">소개</a><a href="#info">이용 정보</a><a href="#units">객실</a><a href="#photos">사진</a><a href="#faq">자주 묻는 질문</a><a href="#contact">위치·연락처</a></nav>
|
||||
<header>
|
||||
<h1 data-ld="name">핑크비치펜션</h1>
|
||||
<p class="category">숙박</p>
|
||||
</header>
|
||||
<section id="intro">
|
||||
<p data-ld="description">하조대 해변에서 도보 3분 거리에 있는 2개 동 규모의 펜션입니다. 모든 객실에서 바다가 보이며, 취사가 가능하고 야외 바비큐 공간을 갖추고 있습니다.</p>
|
||||
</section>
|
||||
<section id="contact">
|
||||
<h2>위치·연락처</h2>
|
||||
<dl>
|
||||
<dt>주소</dt><dd data-ld="streetAddress">강원특별자치도 양양군 현북면 하조대3길 11</dd>
|
||||
<dt>전화</dt><dd><a data-ld="telephone" href="tel:0336720000">033-672-0000</a></dd>
|
||||
</dl>
|
||||
</section>
|
||||
<section id="info">
|
||||
<h2>이용 정보</h2>
|
||||
<dl>
|
||||
<dt>체크인 시간</dt><dd data-ld="checkinTime">15:00</dd>
|
||||
<dt>체크아웃 시간</dt><dd data-ld="checkoutTime">11:00</dd>
|
||||
<dt>취소·환불 규정</dt><dd>이용 7일 전 100% 환불, 3일 전 50% 환불, 당일 취소 불가</dd>
|
||||
<dt>취사 가능</dt><dd>가능</dd>
|
||||
<dt>반려동물 동반</dt><dd data-ld="petsAllowed">불가</dd>
|
||||
<dt>흡연 가능</dt><dd>불가</dd>
|
||||
<dt>프런트 운영시간</dt><dd>09:00 - 21:00</dd>
|
||||
<dt>주차 가능</dt><dd data-ld-amenity="주차 가능">가능</dd>
|
||||
<dt>주차 대수</dt><dd>6대</dd>
|
||||
<dt>와이파이</dt><dd data-ld-amenity="와이파이">가능</dd>
|
||||
<dt>바비큐 이용</dt><dd data-ld-amenity="바비큐 이용">가능</dd>
|
||||
<dt>바비큐 이용료</dt><dd>20,000원</dd>
|
||||
<dt>조식 제공</dt><dd data-ld-amenity="조식 제공">불가</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<section id="units">
|
||||
<h2>객실</h2>
|
||||
<article>
|
||||
<h3>A동 스탠다드</h3>
|
||||
<dl>
|
||||
<dt>객실 타입</dt><dd>스탠다드</dd>
|
||||
<dt>기준 인원</dt><dd>2명</dd>
|
||||
<dt>최대 인원</dt><dd>4명</dd>
|
||||
<dt>침대 구성</dt><dd>퀸 1</dd>
|
||||
<dt>주방 여부</dt><dd>가능</dd>
|
||||
<dt>전망</dt><dd>오션뷰</dd>
|
||||
<dt>주중 요금</dt><dd data-ld-offer="A동 스탠다드">150,000원</dd>
|
||||
</dl>
|
||||
</article>
|
||||
<article>
|
||||
<h3>B동 복층</h3>
|
||||
<dl>
|
||||
<dt>객실 타입</dt><dd>복층</dd>
|
||||
<dt>기준 인원</dt><dd>4명</dd>
|
||||
<dt>최대 인원</dt><dd>6명</dd>
|
||||
<dt>침대 구성</dt><dd>퀸 1 + 싱글 2</dd>
|
||||
<dt>주방 여부</dt><dd>가능</dd>
|
||||
<dt>전망</dt><dd>오션뷰</dd>
|
||||
<dt>주중 요금</dt><dd data-ld-offer="B동 복층">220,000원</dd>
|
||||
</dl>
|
||||
</article>
|
||||
</section>
|
||||
<section id="photos">
|
||||
<h2>사진</h2>
|
||||
<div class="photos">
|
||||
<figure>
|
||||
<button type="button">
|
||||
<img src="https://picsum.photos/seed/pension-out/1200/800" alt="2층 목조 건물 외관과 앞마당 잔디" data-caption="외관" loading="lazy">
|
||||
</button>
|
||||
<figcaption>외관</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<button type="button">
|
||||
<img src="https://picsum.photos/seed/pension-bed/1200/800" alt="퀸 침대와 창밖으로 바다가 보이는 침실" data-caption="A동 침실" loading="lazy">
|
||||
</button>
|
||||
<figcaption>A동 침실</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<button type="button">
|
||||
<img src="https://picsum.photos/seed/pension-liv/1200/800" alt="소파와 원목 테이블이 있는 거실" data-caption="A동 거실" loading="lazy">
|
||||
</button>
|
||||
<figcaption>A동 거실</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<button type="button">
|
||||
<img src="https://picsum.photos/seed/pension-bbq/1200/800" alt="지붕이 있는 야외 바비큐 공간" data-caption="바비큐장" loading="lazy">
|
||||
</button>
|
||||
<figcaption>바비큐장</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<button type="button">
|
||||
<img src="https://picsum.photos/seed/pension-duo/1200/800" alt="계단으로 이어진 복층 구조의 객실" data-caption="B동 복층" loading="lazy">
|
||||
</button>
|
||||
<figcaption>B동 복층</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
<section id="faq" class="faq">
|
||||
<h2>자주 묻는 질문</h2>
|
||||
<details open>
|
||||
<summary>체크인·체크아웃은 몇 시인가요?</summary>
|
||||
<p>체크인은 15:00, 체크아웃은 11:00 입니다.</p>
|
||||
</details>
|
||||
<details open>
|
||||
<summary>반려동물과 함께 갈 수 있나요?</summary>
|
||||
<p>반려동물 동반은 불가합니다.</p>
|
||||
</details>
|
||||
<details open>
|
||||
<summary>객실에서 취사가 가능한가요?</summary>
|
||||
<p>취사가 가능하며 모든 객실에 주방이 있습니다.</p>
|
||||
</details>
|
||||
<details open>
|
||||
<summary>바비큐를 이용하려면 얼마인가요?</summary>
|
||||
<p>바비큐 이용료는 20,000원입니다.</p>
|
||||
</details>
|
||||
<details open>
|
||||
<summary>주차는 가능한가요?</summary>
|
||||
<p>주차가 가능하며 6대까지 주차할 수 있습니다.</p>
|
||||
</details>
|
||||
</section>
|
||||
</main>
|
||||
<div class="lightbox" aria-hidden="true">
|
||||
<button type="button" class="close" aria-label="닫기">×</button>
|
||||
<button type="button" class="nav prev" aria-label="이전 사진">‹</button>
|
||||
<button type="button" class="nav next" aria-label="다음 사진">›</button>
|
||||
<figure>
|
||||
<img src="" alt="">
|
||||
<figcaption></figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<div class="cta"><a class="primary" href="tel:0336720000">전화하기</a><button type="button" data-copy="강원특별자치도 양양군 현북면 하조대3길 11">주소 복사</button></div>
|
||||
<script>
|
||||
(function(){try{
|
||||
var box=document.querySelector('.lightbox');
|
||||
if(box){
|
||||
var img=box.querySelector('img'),cap=box.querySelector('figcaption'),i=0,
|
||||
shots=[].slice.call(document.querySelectorAll('.photos button'));
|
||||
var show=function(n){i=(n+shots.length)%shots.length;var t=shots[i].querySelector('img');
|
||||
img.src=t.src;img.alt=t.alt;cap.textContent=t.dataset.caption||t.alt;};
|
||||
var open=function(n){show(n);box.classList.add('on');document.body.style.overflow='hidden';
|
||||
box.querySelector('.close').focus();};
|
||||
var close=function(){box.classList.remove('on');document.body.style.overflow='';
|
||||
if(shots[i])shots[i].focus();};
|
||||
shots.forEach(function(b,n){b.addEventListener('click',function(){open(n);});});
|
||||
box.querySelector('.close').addEventListener('click',close);
|
||||
box.querySelector('.prev').addEventListener('click',function(){show(i-1);});
|
||||
box.querySelector('.next').addEventListener('click',function(){show(i+1);});
|
||||
box.addEventListener('click',function(e){if(e.target===box)close();});
|
||||
document.addEventListener('keydown',function(e){
|
||||
if(!box.classList.contains('on'))return;
|
||||
if(e.key==='Escape')close();
|
||||
if(e.key==='ArrowLeft')show(i-1);
|
||||
if(e.key==='ArrowRight')show(i+1);});
|
||||
var x0=null;
|
||||
box.addEventListener('touchstart',function(e){x0=e.touches[0].clientX;},{passive:true});
|
||||
box.addEventListener('touchend',function(e){
|
||||
if(x0===null)return;var d=e.changedTouches[0].clientX-x0;x0=null;
|
||||
if(Math.abs(d)>40)show(d<0?i+1:i-1);},{passive:true});
|
||||
}
|
||||
var copy=document.querySelector('[data-copy]');
|
||||
if(copy&&navigator.clipboard){
|
||||
copy.addEventListener('click',function(){
|
||||
navigator.clipboard.writeText(copy.dataset.copy).then(function(){
|
||||
var t=copy.textContent;copy.textContent='복사됨';
|
||||
setTimeout(function(){copy.textContent=t;},1500);});});
|
||||
}else if(copy){copy.remove();}
|
||||
}catch(e){}})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -20,12 +20,12 @@ import type {PlaceData, FactStatus} from '@/api';
|
||||
백엔드 OpenAPI 가 바뀌면 다시 뽑는다. **`generated/` 를 손으로 고치지 않는다.**
|
||||
|
||||
```bash
|
||||
# 백엔드가 떠 있을 때
|
||||
npm run orval -w admin
|
||||
# 레포 루트에서. 백엔드가 떠 있을 때
|
||||
npm run orval
|
||||
|
||||
# 서버 없이 (스펙 파일을 먼저 뽑는다)
|
||||
cd backend && python scripts/export_openapi.py
|
||||
cd ../frontend && ORVAL_INPUT=../../backend/openapi.json npm run orval -w admin
|
||||
cd solution/backend && .venv/bin/python scripts/export_openapi.py
|
||||
cd ../.. && ORVAL_INPUT=solution/backend/openapi.json npm run orval
|
||||
```
|
||||
|
||||
생성되는 것 — 태그(도메인)별 훅과 모델.
|
||||
@ -37,6 +37,9 @@ cd ../frontend && ORVAL_INPUT=../../backend/openapi.json npm run orval -w admin
|
||||
| `fact` | `useGetSchema` `useListFacts` `useUpsertFact` `useTransitionFact` |
|
||||
| `job` | `useGetJob` `useJobOps` `useRequeueJob` |
|
||||
| `site` | `useGetSite` `useStartBuild` `useListVersions` `useListLogs` `useChangeStatus` |
|
||||
| `faq` | `useListFaqs` `useCreateFaq` `useTransitionFaq` |
|
||||
| `media` | `useListMedia` |
|
||||
| `local-content` | `usePublish` `useSyncFestivals` `useUpdateContent` `useEndContent` — 내부 운영 전용 |
|
||||
|
||||
`orval.config.ts` 의 `operationName` 이 FastAPI 의 `list_places_v1_place_list_get` 를
|
||||
`listPlaces` 로 되돌린다 — 백엔드는 무수정이다.
|
||||
|
||||
@ -11,5 +11,5 @@ curl -L -o PretendardVariable.woff2 \
|
||||
https://github.com/orioncactus/pretendard/raw/main/packages/pretendard/dist/web/variable/woff2/PretendardVariable.woff2
|
||||
```
|
||||
|
||||
★ 발행 사이트(`site/public/fonts/`)에도 같은 파일이 필요하다 —
|
||||
프리렌더가 `public/` 을 사이트 폴더로 복사하므로, 두면 자동으로 따라간다.
|
||||
★ 프리렌더가 `public/` 을 굽는 결과물로 복사하므로, 여기 두면 발행 사이트에 자동으로 따라간다.
|
||||
빌더(`solution/frontend/public/fonts/`)는 자기 몫을 따로 갖는다 — 두 앱은 번들이 다르다.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user