Compare commits

...

3 Commits

Author SHA1 Message Date
6bfd48d3c4 문서: 설정 파일이 6개 필요한 것처럼 적혀 있던 것을 바로잡는다
앞 커밋의 표가 여섯 줄을 나란히 놓아서 "클론하면 여섯 개를 만들어야 한다" 로 읽혔다.
실제로 세어 보니 compose 로 띄우는 데 필요한 건 둘이다.

- .env            compose 가 env_file 로 읽는다
- nginx/site.conf compose 가 bind mount 한다

나머지 넷은 필수가 아니다.
- config.local.toml : 도커 이미지가 example 사본을 구워서 나온다(Dockerfile:24).
  호스트에서 python web_main.py 를 직접 돌릴 때만 손으로 만든다.
- config.test.toml  : pytest 를 돌릴 때만.
- 프론트 .env 둘    : VITE_* 가 전부 코드 폴백을 갖고 있고(?? 'http://localhost:9800',
  ?? window.location.host) compose 는 값을 직접 주입한다. 만들 이유가 없다.

표를 필수/선택/안 만들어도 됨으로 갈랐다. 클론 직후 명령도 둘로 줄이고,
toml 은 "호스트에서 돌릴 때만" 이라는 조건을 붙여 아래로 내렸다.
2026-08-31 17:15:47 +09:00
fc47946d3f 설정: 어느 폴더에 .env 와 toml 이 들어가는지 한 표로 못 박는다
세팅할 때마다 "이 값이 어디 가는 거지" 를 코드에서 되짚고 있었다. AGENTS.md 에
설정 파일 지도를 넣고, 흩어져 있던 cp 명령을 거기 한 곳으로 모았다.

두는 곳 — 템플릿이 전부 짝을 갖는다(7개 확인).
  루트                        .env                    ← 백엔드가 읽는 유일한 .env
  solution/backend/config/    config.local.toml       DB·JWT·포트·CORS
  solution/backend/config/    config.test.toml        없으면 pytest 가 import 단계에서 죽는다
  solution/frontend/          .env                    VITE_* (API :9800)
  admin/frontend/             .env                    VITE_* (API :9801)
  nginx/                      site.conf               없으면 Docker 가 디렉토리를 만든다

두지 않는 곳과 그 이유도 같이 적었다 — solution/backend(dotenv 가 루트를 본다),
admin/backend(PYTHONPATH 로 solution 것을 쓴다), solution/site, solution/shared.

solution/site/.env.example 을 지웠다.
"여기엔 아무것도 없다" 는 걸 알리려고 둔 파일인데, .env.example 은 이 레포에서
"복사해서 채워라" 라는 뜻이라 정반대 신호를 준다. 그 설명은 지도의 '두지 않는 곳' 으로 옮겼다.

그 밖에
- config.test.toml.example 의 server_name 이 O2oSiteServerTest 로 남아 있었다.
- config.local.toml.example 에 생략 가능한 [ExternalApiConfig] 필드 셋을 주석으로 적었다.
- .gitignore 의 *.toml 이 레포 전체를 덮는다는 것과, 그래서 pyproject.toml 같은 걸
  추가하면 조용히 무시된다는 것을 규칙 옆에 적었다.
2026-08-31 17:11:25 +09:00
b9d598e214 env: 템플릿을 루트 한 벌로 모으고, .env 를 엉뚱한 데서 읽던 것을 고친다
★ 버그 — 백엔드가 루트 .env 를 안 읽고 있었다.
server_configs.py 의 _DOTENV_PATH 가 dirname 세 번이라 solution/.env 를 보고 있었다.
backend/ 가 solution/ 안으로 들어갈 때 계단을 안 늘린 것이다. 도커는 compose 가 env 를
직접 주입해서 안 드러나고, compose 밖(스크립트·로컬 실행)에서만 외부 API 키가 조용히
안 들어왔다 — 어댑터가 말없이 비활성되는 종류라 아무도 눈치채지 못한다.

solution/backend/.env.example 을 지웠다.
그 자리의 .env 는 위 버그를 고친 뒤에도 아무도 읽지 않는다(루트를 본다).
살아 있던 값은 루트로 옮겼다 — COLLECT_ADAPTERS(기본값을 코드와 맞췄다),
NAVER_COOKIES, SITE_PAYLOAD_DIR. COLLECT_USE_PERPLEXITY 는 버렸다: collect_service 가
"env 로 일괄 활성화하지 않는다" 며 요청 옵션으로 옮겨서, 코드가 더는 읽지 않는다.

루트 .env.example 을 코드·compose 와 맞췄다.
- 코드가 읽는데 템플릿에 없던 것: COLLECT_ADAPTERS · NAVER_COOKIES · SITE_PAYLOAD_DIR ·
  SITE_OUTPUT_DIR · RENDER_TIMEOUT_SEC · ADMIN_API_PORT · JOB_LEASE_SEC · JOB_DEADLINE_SEC ·
  WORKER_CONCURRENCY · JWT_*_EXPIRE_* · GEMINI_*_MODEL · VISION_CONFIDENCE_THRESHOLD
- compose 가 읽는데 템플릿에 없던 것: API_BIND · ADMIN_BIND · ADMIN_API_BIND ·
  ADMIN_API_BASE_URL. 내부 두 개를 0.0.0.0 으로 열면 앱을 가른 의미가 없는데,
  템플릿에 없으면 그 선택지가 있는 줄도 모른다.
- AZURE_STORAGE_PREFIX 기본값을 비웠다. 코드 기본값 ai-for-web 을 그대로 두면 HTML 이
  가리키는 /assets/… 와 블롭 경로가 어긋나 CSS 가 404 다(AGENTS.md 함정).
- 카카오 주석 블록 안에 네이버 설명이 끼어 있고 KAKAO_REST_API_KEY 가 저 아래 떨어져
  있던 것을 공급자별로 다시 묶었다.

앱 쪽 템플릿은 "여기 뭘 적으면 안 되는가"를 적었다.
site 는 런타임 env 를 안 쓴다는 사실을, admin/frontend 는 :9801 을 봐야 한다는 것을 남겼다.
2026-08-31 17:06:06 +09:00
12 changed files with 188 additions and 115 deletions

View File

@ -1,83 +1,130 @@
# o2o-web4ai 환경변수 템플릿.
# o2o-web4ai 환경변수 — 루트 .env 가 단일 출처다.
# cp .env.example .env 후 값을 채운다. .env 는 커밋되지 않는다(.gitignore).
#
# 우선순위: 실제 환경변수(docker-compose 등) > .env > config/config.{APP_ENV}.toml
# DB 접속·JWT 는 config.local.toml 이 기본값이다. 여기 값을 채우면 그쪽을 덮어쓴다.
# 외부 API 키는 toml 을 비워두고 여기서만 관리하는 것을 권장한다.
# 누가 이 파일을 읽나
# · docker compose — env_file 로 통째로, 그리고 ${...} 치환에
# · 백엔드/워커 — compose 밖에서 띄울 때 python-dotenv 가 직접 (config/server_configs.py)
# Vite 앱만 예외다. 자기 디렉토리의 .env 만 읽는 구조라 VITE_* 는 거기에 있다:
# solution/frontend/.env · admin/frontend/.env
#
# 우선순위: 실제 환경변수(compose 등) > 이 파일 > config/config.{APP_ENV}.toml
# ★ 두 곳에 같은 값을 적지 않는다. 발행 호스트는 compose 가 SITE_PUBLIC_HOST 를
# VITE_PUBLISH_HOST 로 흘려보낸다 — 각자 적으면 canonical 과 화면 주소가 조용히 갈라진다.
# ★ APP_ENV=test 면 이 파일을 읽지 않는다. 실키가 새면 테스트가 외부 API 를 때린다.
# ── 실행 ─────────────────────────────────────────────────
# ═══ 실행 ═══════════════════════════════════════════════
APP_ENV=local
# RELOAD=1 # uvicorn --reload (개발 컨테이너)
# SCHEDULER_ENABLED=1 # 배치 스케줄러 기동. 다중 워커면 1개 프로세스에서만 1
# SCHEDULER_ENABLED=1 # 배치 스케줄러 기동(기본 0). 다중 워커면 한 프로세스에서만 1
# WORKER_CONCURRENCY=1 # 워커가 동시에 물 잡 수
# ADMIN_API_PORT=9801 # 내부 API 포트 (admin/backend/main.py)
# ── DB (비우면 config.local.toml 의 [MainDBConfig] 사용) ──
# ── 포트를 어디에 열 것인가 (compose 가 읽는다) ──
# ★ 내부 두 개를 0.0.0.0 으로 열면 앱을 가른 의미가 없다. 기본값이 127.0.0.1 인 이유다.
# API_BIND=0.0.0.0 # 사장님 API :9800 — 외부 공개
# ADMIN_API_BIND=127.0.0.1 # 내부 API :9801
# ADMIN_BIND=127.0.0.1 # 내부 운영 화면 :3002
# ADMIN_API_BASE_URL=http://localhost:9801 # 내부 화면이 부를 API 주소
# ═══ DB (비우면 config.local.toml 의 [MainDBConfig]) ═════
# compose 는 host.docker.internal 을 기본으로 넣는다 — DB 는 compose 밖이다.
# DB_HOST=127.0.0.1
# DB_PORT=5432
# DB_USER=postgres
# DB_PASSWORD=
# DB_NAME=web4ai_db
# ── JWT 서명 키 ───────────────────────────────────────────
# ★ 도커 이미지는 시크릿을 굽지 않으므로(config.local.toml 이 플레이스홀더 사본)
# 컨테이너로 띄울 땐 반드시 여기서 주입해야 한다. 비우면 공개된 플레이스홀더가 서명 키가 된다.
# 생성: python -c "import secrets; print(secrets.token_urlsafe(48))"
# ═══ JWT 서명 키 ════════════════════════════════════════
# ★ 도커 이미지는 시크릿을 굽지 않는다(config.local.toml 이 플레이스홀더 사본).
# 컨테이너로 띄울 땐 반드시 여기서 주입한다 — 비우면 공개된 플레이스홀더가 서명 키가 된다.
# 생성: python3 -c "import secrets; print(secrets.token_urlsafe(48))"
JWT_ACCESS_SECRET=
JWT_REFRESH_SECRET=
# JWT_ACCESS_EXPIRE_MIN=
# JWT_REFRESH_EXPIRE_DAY=
# ── 외부 API 키 ───────────────────────────────────────────
# 비어 있으면 해당 어댑터만 비활성된다. 서버는 그대로 뜬다.
# 채널 URL 발견 (api.perplexity.ai, 모델 sonar / sonar-pro)
# ★ 지금은 **기본으로 꺼져 있다**(COLLECT_USE_PERPLEXITY=0). 키가 있어도 호출하지 않는다.
# 실측(2026-08-27 도플로·버터브루·힐튼 가든 인 서울 강남): 야놀자만 물어오고 네이버
# 플레이스는 0건, 필터를 넓히면 네이버 도움말 페이지를 채널로 등록했다. 게다가
# 검색 호출 요금이 토큰 요금과 별도로 붙는다(생성 1건당 15회).
# ★ 답변을 사실로 쓰지 않는다. URL 발견 전용 — 동명 업소가 섞이고 환각이 있다.
PERPLEXITY_API_KEY=
# ═══ 외부 API 키 ════════════════════════════════════════
# 비어 있으면 해당 어댑터만 비활성된다. 서버는 그대로 뜬다 — 부팅이 외부 계약에 묶이면 안 된다.
# 단가와 원가 상한($1/사이트)은 docs/API_USAGE.md.
# Perplexity 채널 URL 발견 스위치. 0=끔(기본) / 1=켬.
# 코드는 지우지 않고 여기서만 끈다 — 야놀자·여기어때 어댑터가 붙으면 배포 없이 되켠다.
# 끈 상태에서도 상호 → 네이버 place id 직접 해석은 계속 돈다(유일한 자동 발견 경로).
COLLECT_USE_PERPLEXITY=0
# ── 한국관광공사 TourAPI (apis.data.go.kr) — fact 의 주력 공급원 ──
# https://www.data.go.kr 일반 인증키. 무료(개발계정 1,000건/일)
# ★ 디코딩된 키를 넣는다. 인코딩 키를 넣으면 %2B 등이 이중 인코딩된다.
# ★ TourAPI 는 자체 areaCode/sigunguCode 체계다 — 카카오 행정구역 코드와 다르다.
TOUR_API_KEY=
# 동일 업소 검증 · 주변 정보 (dapi.kakao.com)
# https://developers.kakao.com > 내 애플리케이션 > 앱 키 > REST API 키
# ★ 무료 쿼터는 개발자 계정의 "첫 번째 활성 앱" 에만 붙는다.
# dev/stage/prod 앱을 따로 파면 하나만 무료다 — 앱을 나누기 전에 확인할 것.
# 초과 단가: 키워드/카테고리 검색 2원, 좌표 변환 0.5원 (키워드가 4배 비싸다)
# 동일 업소 검증 — 네이버 지역검색 (카카오 키 미발급이라 이쪽을 쓴다)
# ── 네이버 지역검색 (openapi.naver.com) — 동일 업소 검증 ──
# https://developers.naver.com/apps 검색 API
# ★ 제약: display 최대 5건 · telephone 이 빈 값으로 온다 · 행정구역 코드 없음
# → 카카오보다 동일 업소 판정 근거가 약하다(AMBIGUOUS 가 늘어난다)
# ★ 제약: display 최대 5건 · telephone 이 빈 값 · 행정구역 코드 없음
# → 카카오보다 판정 근거가 약하다(AMBIGUOUS 가 늘어난다). 카카오 키가 나오면 그쪽이 우선.
NAVER_CLIENT_ID=
NAVER_CLIENT_SECRET=
# ── 카카오 로컬 (dapi.kakao.com) — 동일 업소 검증 · 주변 정보 ──
# https://developers.kakao.com > 내 애플리케이션 > 앱 키 > REST API 키 (2026-08-31 현재 미발급)
# ★ 무료 쿼터는 개발자 계정의 "첫 번째 활성 앱" 에만 붙는다.
# dev/stage/prod 앱을 따로 파면 하나만 무료다 — 앱을 나누기 전에 확인할 것.
# 초과 단가: 키워드/카테고리 검색 2원, 좌표 변환 0.5원 (키워드가 4배 비싸다)
KAKAO_REST_API_KEY=
# 사진 분류 + 카피 작성 (Google AI Studio)
# ── Gemini (Google AI Studio) — 사진 분류 · 소개문/FAQ · 붙여넣기 추출 ──
# https://aistudio.google.com/apikey
GEMINI_API_KEY=
# GEMINI_VISION_MODEL=gemini-3.7-flash
# GEMINI_TEXT_MODEL=gemini-3.7-flash
# VISION_CONFIDENCE_THRESHOLD=0.7 # 미만이면 자동 반영하지 않고 사람 확인 큐에 남긴다
# 축제 · 관광지 (한국관광공사 TourAPI, data.go.kr)
# ★ TourAPI 는 자체 areaCode/sigunguCode 체계를 쓴다 — 카카오 행정구역 코드와 다르므로 매핑이 필요하다.
# 디코딩된 서비스키를 넣는다(인코딩 키를 넣으면 %2B 등이 이중 인코딩된다).
TOUR_API_KEY=
# ── Perplexity Sonar — 채널 URL 발견 ──
# ★ 요청 단위 옵션으로만 켠다("추가 채널까지 AI로 찾기"). 서버 스위치는 없다 —
# env 로 일괄 활성화하면 재수집마다 사용자가 모르는 유료 검색이 반복된다.
# ★ 답변을 사실로 쓰지 않는다. URL 발견 전용 — 동명 업소가 섞이고 환각이 있다.
PERPLEXITY_API_KEY=
# Open-Meteo(날씨)는 API 키가 필요 없다. 좌표만 있으면 된다.
# ── 발행 호스트 ──
# 커스텀 도메인이 없는 사이트의 공개 주소(`https://<이 값>/s/<slug>`).
# canonical·og:url·sitemap·IndexNow 가 전부 이 값을 쓴다. 비우면 w4ai.o2o.kr.
# ★ 프론트(admin)의 VITE_PUBLISH_HOST 와 **같은 값**이어야 화면과 발행본이 갈리지 않는다.
# Open-Meteo(날씨)는 키가 필요 없다. 좌표만 있으면 된다.
# ═══ 수집 ═══════════════════════════════════════════════
# 켤 어댑터 id(쉼표 구분). 배포 없이 채널 하나를 내릴 수 있게 코드가 아니라 env 다.
# 비우면 코드 기본값과 같다.
COLLECT_ADAPTERS=mock,naver_place,tour_api,static_html
# 네이버 플레이스 GraphQL 은 익명 요청을 429 로 막는다.
# 정상 보유한 세션 쿠키가 있으면 여기 둔다. ★ IP 회전·핑거프린트 위조는 하지 않는다.
NAVER_COOKIES=
# ═══ 발행 ═══════════════════════════════════════════════
# 커스텀 도메인이 없는 사이트의 공개 주소(https://<이 값>/s/<slug>).
# canonical·og:url·sitemap·IndexNow 가 전부 이 값을 쓴다.
# ★ origin 은 payload JSON 에 구워진다 — 바꾸면 프리렌더 재실행이 아니라 백엔드에서 재발행해야 한다.
SITE_PUBLIC_HOST=w4ai.o2o.kr
# Azure Blob 정적 사이트 발행. 비우면 기존 로컬 out/ 발행만 사용한다.
AZURE_STORAGE_CONNECTION_STRING=
AZURE_STORAGE_CONTAINER=$web
AZURE_STORAGE_PREFIX=ai-for-web
# 산출물 경로. 기본값이 컨테이너 경로라, compose 밖에서 돌릴 땐 여기를 덮어야 한다.
# SITE_PAYLOAD_DIR=/app/out/payloads # BUILD 잡이 payload JSON 을 떨어뜨릴 곳(프리렌더의 유일한 입력)
# SITE_OUTPUT_DIR=/app/out/sites # 프리렌더가 구운 정적 트리
# RENDER_TIMEOUT_SEC=180 # .status 보고서를 기다리는 시간
# ── 색인 통보(IndexNow) ──
# 발행 즉시 네이버·Bing·Yandex 에 URL 을 알린다. 비우면 통보를 건너뛴다(발행은 정상).
# 구글은 IndexNow 를 지원하지 않는다 — 구글 쪽은 Search Console 사이트맵 제출이 별도 경로다.
# 값은 8~128자의 영문·숫자·하이픈 아무 문자열이면 된다(비밀이 아니다. 공개되어야 작동한다):
# python3 -c "import uuid; print(uuid.uuid4().hex)"
# ── 잡 큐 ──
# JOB_LEASE_SEC=120 # 리스 만료 = 죽은 워커의 잡을 회수하는 기준
# JOB_DEADLINE_SEC=900 # 이 시간을 넘기면 DEAD
# ── 색인 통보(IndexNow) — 네이버·Bing·Yandex ──
# 비우면 통보를 건너뛴다(발행은 정상). 구글은 IndexNow 미지원 → Search Console 사이트맵 제출.
# ★ 백엔드와 프리렌더가 같은 값이어야 한다. 프리렌더가 루트에 <key>.txt 를 굽고 검색엔진이
# 대조한다 — 어긋나면 403. 비밀이 아니다(공개되어야 작동한다).
# 생성: python3 -c "import uuid; print(uuid.uuid4().hex)"
INDEXNOW_KEY=
# ── Azure Blob 정적 발행 (지금은 끈다 — docs/ARCHITECTURE.md 3절) ──
# 연결 문자열이 비면 발행 잡이 업로드 단계를 통째로 건너뛴다. 서빙은 nginx 가 맡는다.
AZURE_STORAGE_CONNECTION_STRING=
# ★ 셸에서 export 할 땐 반드시 작은따옴표 — '$web'. 큰따옴표면 빈 문자열이 된다.
AZURE_STORAGE_CONTAINER=$web
# ★ 비워 둔다. HTML 이 /assets/… 를 루트 절대경로로 가리키는데 접두사를 쓰면 블롭은
# <접두사>/assets/… 에 놓여 CSS 가 404 다. 오리진 경로를 매핑하는 CDN 을 앞에 세운 뒤에만 채운다.
# (코드 기본값은 ai-for-web 이므로 빈 값을 명시적으로 적어 둔다)
AZURE_STORAGE_PREFIX=

3
.gitignore vendored
View File

@ -4,6 +4,9 @@
!.env.example
# 설정: 시크릿 포함이라 커밋하지 않는다. *.example 만 커밋한다.
# ★ 레포 전체의 *.toml 이 대상이다. toml 은 DB 비밀번호·JWT 키가 들어가는 파일이라
# 안전한 쪽으로 넓게 잡았다 — 대신 pyproject.toml 같은 걸 새로 추가하면 조용히
# 무시되므로, 그때는 여기에 예외(!)를 명시적으로 적는다.
*.toml
!*.toml.example

View File

@ -96,8 +96,7 @@ docker compose logs -f worker
- 사장님 앱 `:3000`(API :9800) · 내부 운영 `:3002`(API :9801, 로컬호스트에만 열림)
- 발행 사이트: `http://localhost:3000/s/<slug>` (빌더 Vite 가 :3001 정적서버로 프록시)
- **클론 직후 1회**: `cp .env.example .env` · `cp nginx/site.conf.example nginx/site.conf`
(후자를 빼먹으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다)
- **클론 직후 1회**: 설정 파일 복사 — 아래 "설정 파일은 어느 폴더에 두나"
- DB 는 compose 밖이다 (호스트 PostgreSQL, `host.docker.internal`).
스키마는 `postgres-init/init-data/init.sql` **한 벌**이다 — 누적 ALTER 파일은 없다
- npm 워크스페이스 루트는 **레포 루트**다. `npm install` 은 루트에서 한 번.
@ -106,15 +105,57 @@ docker compose logs -f worker
- 테스트: `solution/backend/` 에서 `.venv/bin/pytest`. **`APP_ENV=test` 면 `.env` 를 읽지 않는다** —
실키가 테스트로 새어 외부 API 요금이 나가는 경로를 막아 뒀다
## .env 는 어디에 두나
## 설정 파일은 어느 폴더에 두나
**루트 `.env` 가 단일 출처다.** compose 가 이 파일만 읽고(`${...}` 치환 + `env_file`),
Vite 앱은 구조상 **자기 디렉토리의 `.env` 만** 읽는다 — 그래서 나뉘어 있는 것이지 취향이 아니다.
**`docker compose up -d` 에 꼭 필요한 건 두 개다.** 나머지는 호스트에서 파이썬을 직접
돌릴 때만 붙는다 — 앱마다 설정이 흩어져 있는 게 아니다.
| 파일 | 담는 것 |
| | 폴더 | 파일 | 없으면 |
|---|---|---|---|
| **필수** | 레포 루트 | `.env` | compose 가 `env_file` 로 읽는다. DB·JWT·API 키가 안 들어간다 |
| **필수** | `nginx/` | `site.conf` | compose 가 bind mount 한다. **Docker 가 그 자리에 디렉토리를 만들어** nginx 가 설정 없이 뜬다 |
| 선택 | `solution/backend/config/` | `config.local.toml` | 호스트에서 백엔드를 직접 돌릴 때만. **도커 이미지는 이걸 구워서 나온다**(`Dockerfile:24`) |
| 선택 | `solution/backend/config/` | `config.test.toml` | `pytest` 가 conftest import 단계에서 죽는다 |
| 안 만들어도 됨 | `solution/frontend/` | `.env` | 코드에 폴백이 다 있고(`?? 'http://localhost:9800'`), compose 는 값을 직접 주입한다 |
| 안 만들어도 됨 | `admin/frontend/` | `.env` | 〃 |
전부 `*.example` 이 짝으로 있고, 실제 값은 하나도 커밋되지 않는다.
**두지 않는 곳** — 두면 조용히 무시된다.
| 폴더 | 왜 없나 |
|---|---|
| `.env` | DB · JWT · 외부 API 키 · `SITE_PUBLIC_HOST` · `INDEXNOW_KEY` |
| `solution/frontend/.env` · `admin/frontend/.env` | 그 앱에만 있는 `VITE_*`. admin 은 API 가 :9801 이다 |
| `solution/backend/` | `.env` 를 여기 두면 아무도 안 읽는다. dotenv 가 **레포 루트**를 본다 (`config/server_configs.py`) |
| `admin/backend/` | 진입점 두 파일뿐이다. `config/` 는 `PYTHONPATH` 로 `solution/backend` 것을 그대로 쓴다 |
| `solution/site/` | 렌더러는 런타임 env 를 안 쓴다 — 사이트별 값은 전부 `SitePayload` 로 들어온다. 프리렌더 옵션은 CLI 인자(`--payload` `--out`)이고, `INDEXNOW_KEY`·`PORT` 는 compose 가 프로세스에 넣는다 |
| `solution/shared/` | 코드만 있다 |
### 클론 직후 한 번
```bash
cp .env.example .env # JWT_* 는 반드시 채운다(아래 참고)
cp nginx/site.conf.example nginx/site.conf
```
호스트에서 백엔드를 돌리거나 `pytest` 를 쓸 때만 추가로:
```bash
cp solution/backend/config/config.local.toml.example solution/backend/config/config.local.toml
cp solution/backend/config/config.test.toml.example solution/backend/config/config.test.toml
```
### 누가 무엇을 읽나 — 우선순위
**실제 환경변수(compose) > 루트 `.env` > `config.{APP_ENV}.toml`**
- compose 는 루트 `.env` 를 `env_file` 로 통째로 넣고 `${...}` 치환에도 쓴다.
- 백엔드는 **compose 밖에서 띄울 때** 루트 `.env` 를 python-dotenv 로 직접 읽는다.
- Vite 앱만 구조상 **자기 디렉토리의 `.env`** 를 읽는다 — 그래서 나뉜 것이지 취향이 아니다.
- 도커 이미지는 시크릿을 굽지 않는다. `config.local.toml` 을 **example 사본(플레이스홀더)으로
덮어** 넣으므로(`Dockerfile:24`), 컨테이너로 띄울 땐 `JWT_*` 를 env 로 반드시 주입해야 한다.
안 하면 공개된 플레이스홀더가 서명 키가 된다.
- **`APP_ENV=test` 면 `.env` 를 읽지 않는다.** 실키가 테스트로 새어 외부 API 요금이 나가는
경로를 막아 뒀다 — 그래서 `config.test.toml` 의 키는 전부 빈 값이어야 한다.
★ **두 곳에 같은 값을 적지 않는다.** 발행 호스트는 compose 가 루트의 `SITE_PUBLIC_HOST` 를
`VITE_PUBLISH_HOST` 로 흘려보낸다. 각자 적으면 canonical 과 화면 주소가 조용히 갈라진다.

View File

@ -13,8 +13,11 @@
## 실행
```bash
cp .env.example .env # DB_*, JWT_*, 외부 API 키
cp nginx/site.conf.example nginx/site.conf # 빼먹으면 nginx 가 설정 없이 뜬다
# 클론 직후 1회 — compose 에 필요한 건 이 둘뿐이다.
# 호스트에서 백엔드·pytest 를 직접 돌릴 때 붙는 toml 은 AGENTS.md 참고.
cp .env.example .env # JWT_* 를 채운다
cp nginx/site.conf.example nginx/site.conf
docker compose up -d
docker compose logs -f worker
```

View File

@ -1,4 +1,6 @@
# 내부 운영 앱. Vite 는 .env 를 **자기 디렉토리에서만** 읽으므로 여기 둔다.
# ★ 사장님 앱과 겹치는 값(발행 호스트 등)은 여기 적지 않는다 — 루트 .env 가 단일 출처이고
# compose 가 주입한다. 두 곳에 적으면 언젠가 갈라진다.
# 내부 운영 앱(Vite). Vite 는 .env 를 자기 디렉토리에서만 읽으므로 여기 둔다.
# ★ 루트 .env 와 겹치는 값(발행 호스트 등)은 여기 적지 않는다 — compose 가 주입한다.
# 내부 API(:9801)를 본다. :9800 을 보면 API 를 가른 의미가 없다.
# compose 로 띄우면 루트 .env 의 ADMIN_API_BASE_URL 이 이 값을 덮는다.
VITE_API_BASE_URL=http://localhost:9801

View File

@ -1,31 +0,0 @@
# ── 수집 어댑터 ────────────────────────────────────────────
# 켤 어댑터 id(쉼표 구분). 배포 없이 채널 하나를 내릴 수 있게 코드가 아니라 env 로 둔다.
COLLECT_ADAPTERS=mock,naver_place
# 네이버 플레이스 GraphQL 은 익명 요청을 429 로 막는다.
# 정상 보유한 세션 쿠키가 있으면 여기에 둔다. ★ IP 회전·핑거프린트 위조는 하지 않는다.
NAVER_COOKIES=
# ── 채널 URL 발견 ──────────────────────────────────────────
# Perplexity 로 채널 URL 을 찾을지. **기본 0(끔)** — 코드는 남아 있고 env 로만 켠다.
#
# ★ 왜 껐나 (실측 2026-08-27)
# · '도플로'·'버터브루'·'힐튼 가든 인 서울 강남' 세 곳 전부 야놀자만 물어왔다.
# 네이버 플레이스는 **0건**. 도메인 필터를 map.naver.com 까지 넓혔더니 이번엔
# pages.map.naver.com/useful-tips(네이버 도움말 페이지)를 이 가게 채널로 등록했다.
# · 검색 호출 요금이 토큰 요금과 별도로 붙는다. 생성 1건마다 15회씩 나갔다.
# · 대신 네이버 플레이스는 **URL 만 있으면 100%** 된다(fact 5~7건 + 사진 10장).
# 그래서 1순위는 "사장님이 네이버 지도 주소를 붙여넣는다", 2순위는 상호로 place id
# 직접 해석(services/external/naver_place_lookup.py)이다.
#
# 1 로 켜는 시점: 야놀자·여기어때 어댑터가 붙어서, 그 채널 URL 을 찾는 값이 생겼을 때.
COLLECT_USE_PERPLEXITY=0
# ── 발행 산출물 ────────────────────────────────────────────
# BUILD 잡이 발행 payload JSON 을 떨어뜨릴 디렉토리. 없으면 만든다.
# 이 JSON 하나가 정적 렌더러(solution/site)의 유일한 입력이다 —
# npm run prerender -- --payload=<이 디렉토리 또는 그 안의 파일> → out/<slug>/index.html
# 컨테이너 밖(볼륨·오브젝트 스토리지)으로 빼기 쉬우라고 코드가 아니라 env 로 둔다.
# ★ 쓰기에 실패해도 발행은 진행된다(경고 로그만). 발행 기록은 DB 가 진실이다.
SITE_PAYLOAD_DIR=/app/out/payloads

View File

@ -85,7 +85,7 @@ UNVERIFIED ──┬─> PENDING_OWNER ──┬─> VERIFIED ──┬─> CORR
```
o2o-web4ai/
├── .env.example # 외부 API 키·DB 접속 주입 템플릿 (cp .env.example .env)
├── .env.example # ★ 루트다. 백엔드가 읽는 유일한 .env (cp .env.example .env)
├── postgres-init/
│ └── init-data/init.sql # 스키마 DDL 한 벌 (재실행 안전. 누적 ALTER 파일은 없다)
├── admin/backend/ # 내부 API 진입점(:9801). 도메인 코드는 아래를 그대로 import 한다
@ -196,9 +196,13 @@ POST /v1/place/{id}/site/slug {slug} → sites.domain 에 저장(사
| 무엇 | 어디 | 커밋 |
|---|---|---|
| DB 접속 · JWT · 포트 | `solution/backend/config/config.local.toml` | ✗ (`*.toml` ignore) |
| 외부 API 키 | 레포 최상위 `.env` | ✗ |
| 템플릿 | `config/config.local.toml.example` · `.env.example` | ✓ |
| DB 접속 · JWT · 포트 · CORS | `solution/backend/config/config.local.toml` | ✗ (`*.toml` ignore) |
| 테스트 DB | `solution/backend/config/config.test.toml` | ✗ |
| 외부 API 키 · 수집/발행 스위치 | **레포 루트 `.env`** | ✗ |
| 템플릿 | 위 셋의 `*.example` | ✓ |
★ `solution/backend/.env` 는 **아무도 읽지 않는다** — dotenv 가 레포 루트를 본다.
어느 폴더에 무엇을 두는지는 [../../AGENTS.md](../../AGENTS.md) 가 단일 출처다.
우선순위: **실제 환경변수(docker-compose) > `.env` > `config.{APP_ENV}.toml`**

View File

@ -49,4 +49,8 @@ kakao_rest_api_key = "" # 동일 업소 검증(미발급)
naver_client_id = "" # 동일 업소 검증 — 네이버 지역검색
naver_client_secret = ""
gemini_api_key = "" # 사진 분류 · 카피 작성
tour_api_key = "" # 공공데이터포털 전국문화축제표준데이터 일반 인증키
tour_api_key = "" # 공공데이터포털 일반 인증키. fact 의 주력 공급원
# 아래 셋은 생략하면 코드 기본값을 쓴다(config_models.ExternalApiConfig).
# gemini_vision_model = "gemini-3.7-flash"
# gemini_text_model = "gemini-3.7-flash"
# vision_confidence_threshold = 0.7 # 미만이면 사람 확인 큐에 남긴다

View File

@ -6,7 +6,7 @@
# ★ DB 이름을 dev 와 절대 같게 두지 말 것 — 픽스처가 TRUNCATE 를 돌린다.
# conftest 의 안전가드가 이름을 확인하지만, 여기서부터 갈라 두는 게 먼저다.
[WebServerConfig]
server_name = "O2oSiteServerTest"
server_name = "Web4aiServerTest"
port = 9800
process_count = 1
is_ssl = false

View File

@ -12,7 +12,11 @@ from config.config_models import WebServerConfig, LogConfig, MainDBConfig, JwtTo
# ★ APP_ENV=test 면 .env 를 읽지 않는다. 실키가 테스트 환경에 새어 들어가면
# 테스트가 실제 외부 API(Perplexity·Gemini·카카오)를 때리고 요금이 나간다.
# 테스트의 외부 연동은 config.test.toml(전 키 빈 값) + MockAdapter 로만 검증한다.
_DOTENV_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), ".env")
# config/ → backend/ → solution/ → 레포 루트. 백엔드가 solution/ 안으로 들어갈 때
# 이 계단을 안 늘려서 solution/.env 를 보고 있었다 — 도커는 compose 가 env 를 직접
# 주입해 안 드러나고, compose 밖(스크립트·로컬 실행)에서만 키가 조용히 안 들어왔다.
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
_DOTENV_PATH = os.path.join(_REPO_ROOT, ".env")
if os.environ.get("APP_ENV") != "test":
load_dotenv(_DOTENV_PATH, override=False)

View File

@ -1,16 +1,18 @@
# 관리자(빌더)가 호출할 백엔드. o2o-web4ai 백엔드 기본 포트는 9800.
# 사장님 빌더(Vite). Vite 는 .env 를 자기 디렉토리에서만 읽으므로 여기 둔다.
# ★ 루트 .env 와 겹치는 값은 여기 적지 않는다 — compose 가 주입한다. 두 곳에 적으면 갈라진다.
# 빌더가 호출할 사장님 API. 백엔드 기본 포트는 9800(내부 API :9801 이 아니다).
VITE_API_BASE_URL=http://localhost:9800
# 발행 사이트의 기본 호스트. 발행 모달이 보여줄 주소를 이걸로 만든다.
# 커스텀 도메인이 붙기 전까지 <slug>.<이 값> 형태로 나간다.
# ★ 로컬에서 compose 없이 띄울 때만 쓴다. compose 로 띄우면 루트 .env 의 SITE_PUBLIC_HOST 가
# 주입돼 이 값을 덮는다 — 발행 호스트의 단일 출처는 루트 .env 다.
# ★ compose 로 띄우면 루트 .env 의 SITE_PUBLIC_HOST 가 주입돼 이 값을 덮는다.
# 여기 적는 건 compose 없이 로컬로 띄울 때뿐이다.
VITE_PUBLISH_HOST=w4ai.o2o.kr
# 발행 사이트 렌더러 개발 서버. 에디터 상단 "발행본 사이트 열기" 가 이 주소를 연다.
# 에디터 상단 "발행본 사이트 열기" 가 여는 주소. :3000 이 /s/* 를 정적 서버(:3001)로 프록시한다.
VITE_SITE_PREVIEW_URL=http://localhost:3000
# ── 개발 전용 자동 로그인(선택) ────────────────────────────
# ── 개발 전용 자동 로그인(선택) ──
# 값이 있으면 개발 서버에서 빌더를 열 때 이 계정으로 자동 로그인한다.
# 운영 번들에는 들어가지 않는다. 실제 값은 .env 에만 둔다(커밋 금지).
VITE_DEV_LOGIN_ID=

View File

@ -1,6 +0,0 @@
# 발행 사이트 렌더러는 런타임 환경변수를 쓰지 않는다.
# 사이트마다 다른 값(도메인·색·내용)은 전부 SitePayload 로 들어온다 —
# 그래야 payload 하나로 같은 HTML 이 재현된다.
#
# 프리렌더 옵션은 CLI 인자로 준다:
# npm run prerender -- --payload=./payloads --out=/var/www