[feat] deploy,backend,site: 킹서버 배포 + 설정을 최상위 .env 하나로 통합

킹서버(o2oadmin@172.30.1.36)에 처음 올리면서, 서버에 올려야만 드러나는 결함 넷을 잡았다.
전부 "화면은 뜨는데 안 되는" 종류라 로컬에서는 끝까지 보이지 않는다.

- CORS 허용 오리진(client_url)만 env override 가 없었다. 도커가 굽던 config.local.toml 은
  플레이스홀더라 허용 목록이 localhost:3000~3005 뿐이고, 배포 주소에서는 모든 API 호출이
  프리플라이트에서 죽었다. 서버 로그에는 400 만 남아 원인이 CORS 라는 게 안 보인다
- .env 경로가 세 단계라 solution/.env(없는 파일)를 보고 있었다. 백엔드를 solution/ 아래로
  옮길 때 안 고쳐진 자리. toml 이 값을 들고 있어 로컬에서 드러나지 않았다
- admin 의 "빌더 열기" 가 VITE_SOLUTION_URL 미주입으로 localhost:3000 을 가리켰다
- PUBLIC_API_BASE_URL 은 브라우저가 부르는 주소인데 기본값이 localhost 라 서버에서 즉시 틀린다

설정 — toml 층 제거, pydantic-settings 로 전환 (FastAPI 공식 방식)
- config_loader.py · config.{local,test}.toml.example 삭제, 기본값은 config_models 로
- BaseSettings + env_file. `_apply_*_env_override` 4개 제거 — 키를 손으로 나열하는 구조라
  하나 빠뜨리면 조용히 틀렸고, 실제로 client_url 이 빠져 있었다
- 환경변수 이름은 validation_alias 로 못 박음. 필드명만 두면 `port` 가 흔한 `PORT` 를 먹는다
- 테스트 DB 분리(web4ai_test_db)는 config.test.toml 이 하던 몫이라 APP_ENV 기본값으로 이관
- lru_cache 로 .env 재읽기 방지. 새 코드는 Depends(get_*) 주입 가능
- 호출부 21개 파일 무변경 — server_configs 가 같은 이름을 계속 내보낸다

배포 — 킹서버는 :80 을 호스트 nginx 가 물고 있고 사내망에 열린 건 30xxx 뿐이다
- 컴포즈 포트를 전부 .env 변수로 추출(기본값은 기존 값 그대로, 로컬 무영향)
- 컨테이너 이름을 폴더 구조에 맞춤 — solution-backend·solution-worker·solution-frontend·
  solution-site·admin-backend·admin-frontend. api·web·nginx 는 어느 폴더 코드인지
  이름만으로 알 수 없었고, 백엔드 셋이 이미지 한 벌을 나눠 써서 특히 헷갈렸다
- worker 에 container_name 을 붙여 `-1` 접미사 제거(동시성은 WORKER_CONCURRENCY 가 맡는다)
- 어드민 앱·API 는 compose 프로필 뒤로 — 지금 안 쓴다. 켤 때 --profile admin
- deploy.sh: 서비스 하나를 지정해도 백엔드 형제를 함께 교체한다. 이미지 한 벌을 나눠 써서
  하나만 바꾸면 옛 코드로 도는 컨테이너가 남는데 `ps` 로는 셋 다 살아 있다
- log.sh: 1=전체, 2번부터 개별. compose v2.20 이 커스텀 --format 을 파싱하지 못해 상태가
  전부 "미기동" 으로 보이던 것도 --services --filter 로 교정
- docs/SERVERS.md 신설(접속·경로·포트·DB·sudo 없음), docs/DEVLOG.md 신설

정리
- 개발 전용 자동 로그인 제거 — 편의 하나에 검색 경로의 비동기 대기가 딸려 있었고,
  평문 비밀번호를 .env 에 두라고 권하는 모양새였다
- API 이름을 디렉토리에 맞춤: 사장님/내부 → 솔루션 API · 어드민 API (21곳)
- .env.example 을 읽는 폴더 기준 구역으로 재편 (solution/backend · solution/frontend ·
  solution/site · compose)
- AGENTS.md 에 negosium 브랜치·커밋 규약 명시

검증(킹서버 실측) — 컨테이너 4개 새 이름으로 기동, 솔루션 API·사장님 앱 200,
발행 사이트 404(발행물 없음, 정상), CORS 허용/차단 각 확인, toml 없이 부팅,
APP_ENV=test 시 web4ai_test_db·실키 미주입 확인.
This commit is contained in:
Mina Choi 2026-09-01 10:04:36 +09:00
parent 4871e50327
commit 9b4fe4030b
30 changed files with 774 additions and 523 deletions

View File

@ -24,8 +24,6 @@ solution/backend/tests/
solution/backend/loadtest/
# 시크릿 — 이미지에 굽지 않는다. Dockerfile 이 example 을 복사해 넣고 실값은 compose env 로 준다.
**/config.local.toml
**/config.test.toml
.env
.env.*
!.env.example

View File

@ -1,83 +1,51 @@
# o2o-web4ai 환경변수 템플릿.
# cp .env.example .env 후 값을 채운다. .env 는 커밋되지 않는다(.gitignore).
#
# 우선순위: 실제 환경변수(docker-compose 등) > .env > config/config.{APP_ENV}.toml
# DB 접속·JWT 는 config.local.toml 이 기본값이다. 여기 값을 채우면 그쪽을 덮어쓴다.
# 외부 API 키는 toml 을 비워두고 여기서만 관리하는 것을 권장한다.
# cp .env.example .env 후 값을 채운다. .env 는 커밋되지 않는다.
# 우선순위: 실제 환경변수(compose) > .env > 코드 기본값(config_models.py)
# ── 실행 ─────────────────────────────────────────────────
# ── 공통 solution/backend · admin/backend (server_configs 가 읽는다)
APP_ENV=local
# RELOAD=1 # uvicorn --reload (개발 컨테이너)
# SCHEDULER_ENABLED=1 # 배치 스케줄러 기동. 다중 워커면 1개 프로세스에서만 1
# ── DB (비우면 config.local.toml 의 [MainDBConfig] 사용) ──
# DB_HOST=127.0.0.1
# DB_PORT=5432
# DB_USER=postgres
# DB_PASSWORD=
# DB_NAME=web4ai_db
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_ACCESS_SECRET=
JWT_REFRESH_SECRET=
# ── 외부 API 키 ───────────────────────────────────────────
# 비어 있으면 해당 어댑터만 비활성된다. 서버는 그대로 뜬다.
# 채널 URL 발견 (api.perplexity.ai, 모델 sonar / sonar-pro)
# ★ 지금은 **기본으로 꺼져 있다**(COLLECT_USE_PERPLEXITY=0). 키가 있어도 호출하지 않는다.
# 실측(2026-08-27 도플로·버터브루·힐튼 가든 인 서울 강남): 야놀자만 물어오고 네이버
# 플레이스는 0건, 필터를 넓히면 네이버 도움말 페이지를 채널로 등록했다. 게다가
# 검색 호출 요금이 토큰 요금과 별도로 붙는다(생성 1건당 15회).
# ★ 답변을 사실로 쓰지 않는다. URL 발견 전용 — 동명 업소가 섞이고 환각이 있다.
# 키가 비면 그 어댑터만 꺼진다. 서버는 뜬다.
PERPLEXITY_API_KEY=
# Perplexity 채널 URL 발견 스위치. 0=끔(기본) / 1=켬.
# 코드는 지우지 않고 여기서만 끈다 — 야놀자·여기어때 어댑터가 붙으면 배포 없이 되켠다.
# 끈 상태에서도 상호 → 네이버 place id 직접 해석은 계속 돈다(유일한 자동 발견 경로).
COLLECT_USE_PERPLEXITY=0
# 동일 업소 검증 · 주변 정보 (dapi.kakao.com)
# https://developers.kakao.com > 내 애플리케이션 > 앱 키 > REST API 키
# ★ 무료 쿼터는 개발자 계정의 "첫 번째 활성 앱" 에만 붙는다.
# dev/stage/prod 앱을 따로 파면 하나만 무료다 — 앱을 나누기 전에 확인할 것.
# 초과 단가: 키워드/카테고리 검색 2원, 좌표 변환 0.5원 (키워드가 4배 비싸다)
# 동일 업소 검증 — 네이버 지역검색 (카카오 키 미발급이라 이쪽을 쓴다)
# https://developers.naver.com/apps 검색 API
# ★ 제약: display 최대 5건 · telephone 이 빈 값으로 온다 · 행정구역 코드 없음
# → 카카오보다 동일 업소 판정 근거가 약하다(AMBIGUOUS 가 늘어난다)
COLLECT_USE_PERPLEXITY=0 # Perplexity 채널 발견. 0=끔(기본)
NAVER_CLIENT_ID=
NAVER_CLIENT_SECRET=
KAKAO_REST_API_KEY=
# 사진 분류 + 카피 작성 (Google AI Studio)
# https://aistudio.google.com/apikey
KAKAO_REST_API_KEY= # 미발급. 없으면 네이버 지역검색을 쓴다
GEMINI_API_KEY=
TOUR_API_KEY= # 디코딩된 키(인코딩 키는 이중 인코딩된다)
# 축제 · 관광지 (한국관광공사 TourAPI, data.go.kr)
# ★ TourAPI 는 자체 areaCode/sigunguCode 체계를 쓴다 — 카카오 행정구역 코드와 다르므로 매핑이 필요하다.
# 디코딩된 서비스키를 넣는다(인코딩 키를 넣으면 %2B 등이 이중 인코딩된다).
TOUR_API_KEY=
# CORS 허용 오리진. 쉼표로 여럿.
# 서버에 올리면 반드시 적는다. 안 적으면 화면은 뜨고 API 만 막힌다.
# CLIENT_URL=http://172.30.1.36:30031,http://localhost:3002
# LANDING_URL=
# Open-Meteo(날씨)는 API 키가 필요 없다. 좌표만 있으면 된다.
# ── 발행 호스트 ──
# 커스텀 도메인이 없는 사이트의 공개 주소(`https://<이 값>/s/<slug>`).
# canonical·og:url·sitemap·IndexNow 가 전부 이 값을 쓴다. 비우면 w4ai.o2o.kr.
# ★ 프론트(admin)의 VITE_PUBLISH_HOST 와 **같은 값**이어야 화면과 발행본이 갈리지 않는다.
# ── solution/frontend 브라우저가 부르는 주소 (compose 가 VITE_* 로 주입)
# ★ 브라우저가 부르는 주소다. 서버에 올리면 localhost 는 즉시 틀린다.
# PUBLIC_API_BASE_URL=http://localhost:9800
# PUBLIC_WEB_BASE_URL=http://localhost:3000
# ── solution/site 발행물 — solution/backend 도 같이 본다
# canonical·og:url·sitemap·IndexNow 가 전부 SITE_PUBLIC_HOST 를 쓴다.
SITE_PUBLIC_HOST=w4ai.o2o.kr
INDEXNOW_KEY= # 비우면 색인 통보를 건너뛴다(발행은 정상)
AZURE_STORAGE_CONNECTION_STRING= # 비우면 로컬 발행만 한다
AZURE_STORAGE_CONTAINER=
AZURE_STORAGE_PREFIX=
# Azure Blob 정적 사이트 발행. 비우면 기존 로컬 out/ 발행만 사용한다.
AZURE_STORAGE_CONNECTION_STRING=
AZURE_STORAGE_CONTAINER=$web
AZURE_STORAGE_PREFIX=ai-for-web
# ── 색인 통보(IndexNow) ──
# 발행 즉시 네이버·Bing·Yandex 에 URL 을 알린다. 비우면 통보를 건너뛴다(발행은 정상).
# 구글은 IndexNow 를 지원하지 않는다 — 구글 쪽은 Search Console 사이트맵 제출이 별도 경로다.
# 값은 8~128자의 영문·숫자·하이픈 아무 문자열이면 된다(비밀이 아니다. 공개되어야 작동한다):
# python3 -c "import uuid; print(uuid.uuid4().hex)"
INDEXNOW_KEY=
# ── compose 포트 매핑
# 비우면 로컬 기본값. 서버 값은 docs/SERVERS.md
# SITE_HTTP_PORT=80 # 발행 사이트
# WEB_PORT=3000 # 사장님 앱
# API_PORT=9800 # 사장님 API
# ADMIN_PORT=3002 # 내부 화면 (bind 127.0.0.1)
# ADMIN_API_PORT_PUBLIC=9801 # 내부 API (bind 127.0.0.1)

147
AGENTS.md
View File

@ -9,7 +9,9 @@
| 어떻게 도나 · 앱 경계 | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) |
| **다음에 뭘 만드나** (우선순위 P0~P4) | [docs/DEVELOPMENT_DIRECTION.md](docs/DEVELOPMENT_DIRECTION.md) |
| 미결 사항 · 코드가 그걸 어떻게 격리했나 | [docs/DECISIONS.md](docs/DECISIONS.md) |
| 최근에 뭘 왜 바꿨나 | [docs/DEVLOG.md](docs/DEVLOG.md) |
| 서버에 올릴 때 | [docs/DEPLOY.md](docs/DEPLOY.md) |
| **어느 서버**에 올리나 (킹서버) | [docs/SERVERS.md](docs/SERVERS.md) |
---
@ -32,7 +34,7 @@
- **★ 프론트(`solution/site`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
`azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 —
**렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.**
`docker compose restart web` 후 `python scripts/republish_all.py`
`docker compose restart solution-frontend` 후 `python scripts/republish_all.py`
- **발행 호스트는 두 곳에 있고 같아야 한다.** 백엔드 `SITE_PUBLIC_HOST`(기본 `w4ai.o2o.kr`,
`site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부
이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더
@ -75,8 +77,8 @@ admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
| | 포트 | 진입점 | 권한 |
|---|---|---|---|
| 사장님 API | 9800 | `solution/backend/web_main.py` | 엔드포인트별 |
| 내부 API | 9801 | `admin/backend/main.py``app.py` | **앱 전체 role >= DEVELOPER** |
| 솔루션 API | 9800 | `solution/backend/web_main.py` | 엔드포인트별 |
| 어드민 API | 9801 | `admin/backend/main.py``app.py` | **앱 전체 role >= DEVELOPER** |
`services`·`crud`·`models` 은 그대로 공유한다. admin 화면이 부르는 게 사장님 빌더와 거의
같아서(place·fact, admin 전용은 `local-content` 하나) 도메인을 복제하지 않고 **같은 router
@ -91,7 +93,7 @@ admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
```bash
docker compose up -d # api :9800 · api-admin :9801 · 워커 · web :3000 · admin :3002 · nginx :80
docker compose logs -f worker
docker compose logs -f solution-worker
```
- 사장님 앱 `:3000`(API :9800) · 내부 운영 `:3002`(API :9801, 로컬호스트에만 열림)
@ -119,7 +121,138 @@ Vite 앱은 구조상 **자기 디렉토리의 `.env` 만** 읽는다 — 그래
**두 곳에 같은 값을 적지 않는다.** 발행 호스트는 compose 가 루트의 `SITE_PUBLIC_HOST`
`VITE_PUBLISH_HOST` 로 흘려보낸다. 각자 적으면 canonical 과 화면 주소가 조용히 갈라진다.
## 커밋
## 브랜치 · 커밋
- 한 커밋 = 한 가지 변경. 문서와 그 문서가 설명하는 코드는 같은 커밋에 둔다.
- 커밋 메시지는 한국어로, **무엇을 왜 바꿨는지**. 파일 목록은 `git` 이 이미 안다.
`o2o-negosium` 과 같은 규약이다.
**브랜치는 영문**이다. 한국어 브랜치를 올리지 않는다(워크트리 도구가 한글 이름을 자동으로
만드는데 그대로 push 되기 쉽다).
```
feature/<주제> feat/<주제> 새 기능
fix/<주제> 버그
chore/<주제> 잡일·설정
docs/<주제> 문서
design/<주제> 디자인
```
**커밋 제목: `[type] scope: 요약 — 부연`**
```
[feat] lps/enuri: 오퍼 항목의 판매몰명 식별 — 이미지 CDN 도메인 매핑
[fix] negosium/front: 복기 구멍을 블록 경계에서 끊고 시트 여닫이에 고정
[chore] lps: 구 IQR 이상치 제거 코드 삭제 — 죽은 경로 정리
```
- type: `feat` `fix` `chore` `docs`
- **scope 는 코드 경로**다 — `lps/ai` · `negodata/front` · `postgres-init`.
이 레포라면 `solution/backend` · `site` · `admin/front` · `deploy`.
여러 곳이면 쉼표(`negosium/front,landing`)
- **요약은 명사형으로 끝낸다** — "분리" "추가" "갱신". "~한다" 로 쓰지 않는다
- 부연은 `—` 뒤에 붙인다
**본문은 세 덩이다.**
```
[fix] lps: 행(hang)·병목 가드 — 페이지 상호작용 80s 상한 + AI 호출 타임아웃
crash 로 굳은 페이지는 content() 가 CDP 응답을 상한 없이 기다린다 —
실측(gmarket): 무로그 4분 행 → 잡 데드라인 300s 소진 → 사용자 체감 +5분.
- browser.py: goto+렌더대기+content 를 _fetch_page 로 묶어 80s 단일 상한
- ai/keyword: timeout=45s 명시 — SDK 기본(600s)이 잡 데드라인보다 크다
테스트 4건 추가, 전체 270 passed
```
1. **왜** — 실측값·밟은 함정. 코드를 읽으면 아는 "무엇" 은 쓰지 않는다
2. **변경 항목** — 파일/모듈별 불릿, 항목마다 근거
3. **검증**`tsc·eslint·vite build 통과` · `전체 N passed`
한 커밋 = 한 가지 변경. 문서와 그 문서가 설명하는 코드는 같은 커밋에 둔다.
## 레포 구조
```
solution/ 사장님 — backend · frontend(빌더) · site(발행물) · shared(계약)
admin/ 우리 — backend(진입점만, :9801) · frontend(운영 화면)
```
최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약). `frontend/` `backend/` 를 최상단
묶음 폴더로 쓰지 않는다. 근거와 경계는 [ARCHITECTURE.md 4절](docs/ARCHITECTURE.md).
**의존 방향은 `admin` → `solution` 한 쪽뿐이다.** admin 의 `@` 별칭이 `solution/frontend/src`
가리키고, API 도 solution 백엔드를 본다. 반대 방향이 생기면 가른 의미가 사라진다.
★ **백엔드는 코드 한 벌, 진입점 둘이다.**
| | 포트 | 진입점 | 권한 |
|---|---|---|---|
| 솔루션 API | 9800 | `solution/backend/web_main.py` | 엔드포인트별 |
| 어드민 API | 9801 | `admin/backend/main.py``app.py` | **앱 전체 role >= DEVELOPER** |
`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)).
## 실행
```bash
docker compose up -d # api :9800 · api-admin :9801 · 워커 · web :3000 · admin :3002 · nginx :80
docker compose logs -f solution-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 가 설정 없이 뜬다)
- DB 는 compose 밖이다 (호스트 PostgreSQL, `host.docker.internal`).
스키마는 `postgres-init/init-data/init.sql` **한 벌**이다 — 누적 ALTER 파일은 없다
- npm 워크스페이스 루트는 **레포 루트**다. `npm install` 은 루트에서 한 번.
`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 요금이 나가는 경로를 막아 뒀다
## .env 는 어디에 두나
**루트 `.env` 가 단일 출처다.** compose 가 이 파일만 읽고(`${...}` 치환 + `env_file`),
Vite 앱은 구조상 **자기 디렉토리의 `.env` 만** 읽는다 — 그래서 나뉘어 있는 것이지 취향이 아니다.
| 파일 | 담는 것 |
|---|---|
| `.env` | DB · JWT · 외부 API 키 · `SITE_PUBLIC_HOST` · `INDEXNOW_KEY` |
| `solution/frontend/.env` · `admin/frontend/.env` | 그 앱에만 있는 `VITE_*`. admin 은 API 가 :9801 이다 |
**두 곳에 같은 값을 적지 않는다.** 발행 호스트는 compose 가 루트의 `SITE_PUBLIC_HOST`
`VITE_PUBLISH_HOST` 로 흘려보낸다. 각자 적으면 canonical 과 화면 주소가 조용히 갈라진다.
## 브랜치 · 커밋
`o2o-negosium` 과 같은 규약이다.
**브랜치는 영문**이다. 한국어 브랜치를 올리지 않는다(워크트리 도구가 한글 이름을 자동으로
만들 수 있는데, 그대로 push 하면 안 된다).
```
feature/<주제> 새 기능 fix/<주제> 버그
chore/<주제> 잡일·설정 docs/<주제> 문서
```
**커밋 메시지는 `type(scope): 한국어 설명`.**
```
feat(site): 발행 모달에 커스텀 도메인 안내를 붙인다
fix(backend/config): 배포 주소에서 CORS 가 막히던 것
chore(deploy): 포트를 .env 로 뽑는다
```
- type: `feat` `fix` `chore` `docs` `refactor`
- scope: 건드린 자리(`backend` `site` `admin/front` `lps` `postgres-init` …). 애매하면 생략한다
- 한 커밋 = 한 가지 변경. 문서와 그 문서가 설명하는 코드는 같은 커밋에 둔다
- 본문에는 **왜** 를 적는다. 파일 목록은 `git` 이 이미 안다

View File

@ -16,16 +16,16 @@
cp .env.example .env # DB_*, JWT_*, 외부 API 키
cp nginx/site.conf.example nginx/site.conf # 빼먹으면 nginx 가 설정 없이 뜬다
docker compose up -d
docker compose logs -f worker
docker compose logs -f solution-worker
```
| | 주소 | 공개 |
|---|---|---|
| 사장님 앱 (빌더) | http://localhost:3000 | 외부 |
| 발행된 사이트 | http://localhost:3000/s/`<slug>` · 운영은 :80 | 외부 |
| 사장님 API 문서 | http://localhost:9800/docs | 외부 |
| 솔루션 API 문서 | http://localhost:9800/docs | 외부 |
| **내부 운영 화면** | http://localhost:3002 | **127.0.0.1 만** |
| **내부 API 문서** | http://localhost:9801/docs | **127.0.0.1 만** |
| **어드민 API 문서** | http://localhost:9801/docs | **127.0.0.1 만** |
내부 두 개를 `0.0.0.0` 으로 열면 앱을 가른 의미가 없다 (`ADMIN_BIND` · `ADMIN_API_BIND`).

View File

@ -1,4 +1,4 @@
"""내부 운영 API. 사장님 API(:9800)와 프로세스·포트가 갈린다.
"""어드민 API. 솔루션 API(:9800)와 프로세스·포트가 갈린다.
도메인 코드는 solution/backend 것을 PYTHONPATH 쓴다 admin 전용 라우터가 0개라
(전부 place·fact) 새로 쓰면 같은 테이블을 구현하는 것뿐이다.

View File

@ -1,4 +1,4 @@
# 내부 운영 API 서버 (:9801). 근거는 app.py 주석.
# 어드민 API 서버 (:9801). 근거는 app.py 주석.
# PYTHONPATH=../../solution/backend python main.py
import os

102
deploy.sh Executable file
View File

@ -0,0 +1,102 @@
#!/usr/bin/env bash
# 배포 — 코드를 당기고, 지정한 서비스만 다시 빌드해 갈아끼운다.
#
# ./deploy.sh 전체
# ./deploy.sh solution-backend 그 서비스만
# ./deploy.sh solution-backend solution-worker 여럿
#
# 서비스명 대신 컨테이너명(o2o-web4ai-solution-backend)으로 불러도 받는다.
set -euo pipefail
cd "$(dirname "$0")"
PREFIX=o2o-web4ai
# api·worker·api-admin 은 이미지 한 벌(o2o-web4ai-backend)을 나눠 쓴다.
BACKEND_SVCS=(solution-backend solution-worker admin-backend)
PULL=1
ONLY=0
TARGETS=()
usage() {
cat <<'USAGE'
사용법: ./deploy.sh [옵션] [서비스...]
옵션
--no-pull git pull 을 건너뛴다(디스크에 있는 코드 그대로 빌드)
--only 백엔드 형제 서비스를 함께 갈아끼우지 않는다 (아래 ★ 참고)
-h, --help
서비스: solution-backend · solution-worker · solution-frontend · solution-site
admin-backend · admin-frontend (프로필 admin, 기본 기동에서 빠져 있다)
o2o-web4ai-solution-backend 처럼 컨테이너명으로 적어도 된다
★ solution-backend·solution-worker·admin-backend 는 이미지가 한 벌이다. 하나를 빌드하면 나머지도 새 이미지로
갈아끼워야 한다 — 안 그러면 옛 코드로 도는 컨테이너가 남는데, 셋 다 "살아 있음" 이라
화면상으로는 배포가 끝난 것처럼 보인다. --only 는 그걸 알고 건너뛸 때만 쓴다.
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--no-pull) PULL=0 ;;
--only) ONLY=1 ;;
-h|--help) usage; exit 0 ;;
-*) echo "모르는 옵션: $1" >&2; usage >&2; exit 2 ;;
*) TARGETS+=("${1#"$PREFIX"-}") ;; # 컨테이너명으로 불러도 받는다
esac
shift
done
ALL_SVCS=$(docker compose config --services)
for t in ${TARGETS+"${TARGETS[@]}"}; do
grep -qx "$t" <<<"$ALL_SVCS" || {
echo "그런 서비스가 없다: $t" >&2
echo "있는 것: $(tr '\n' ' ' <<<"$ALL_SVCS")" >&2
exit 2
}
done
# ── 코드 ────────────────────────────────────────────────────────────
if [ "$PULL" = 1 ]; then
echo "▶ git pull"
if ! git pull --ff-only 2>&1; then
echo " ! 당기지 못했다 — 디스크에 있는 코드 그대로 간다." >&2
echo " (원격 인증이 없으면 밖에서 밀어넣고 --no-pull 로 돌린다)" >&2
fi
fi
echo "▶ 지금 코드: $(git log --oneline -1)"
# ── 대상 정하기 ─────────────────────────────────────────────────────
if [ ${#TARGETS[@]} -eq 0 ]; then
SVCS=() # 빈 인자 = compose 가 전부로 해석한다
echo "▶ 대상: 전체"
else
SVCS=("${TARGETS[@]}")
# 백엔드 하나를 건드리면 같은 이미지를 쓰는 형제도 함께 갈아끼운다.
if [ "$ONLY" = 0 ]; then
for t in "${TARGETS[@]}"; do
for b in "${BACKEND_SVCS[@]}"; do [ "$t" = "$b" ] || continue
for sib in "${BACKEND_SVCS[@]}"; do
printf '%s\n' "${SVCS[@]}" | grep -qx "$sib" || {
SVCS+=("$sib")
echo " + $sib — 같은 이미지를 쓴다(옛 코드로 남지 않게 함께 간다)"
}
done
done
done
fi
echo "▶ 대상: ${SVCS[*]}"
fi
# ── 빌드 · 교체 ─────────────────────────────────────────────────────
# build 절이 없는 서비스(node:24-alpine · nginx:alpine)는 build 가 조용히 건너뛴다.
echo "▶ build"
docker compose build ${SVCS+"${SVCS[@]}"}
echo "▶ up -d"
docker compose up -d --force-recreate ${SVCS+"${SVCS[@]}"}
echo
docker compose ps
echo
echo "로그: ./log.sh"

View File

@ -5,15 +5,15 @@ name: o2o-web4ai
# DB 는 compose 밖이다(호스트 PostgreSQL).
# ★ 컨테이너 이름이 negosium-db 인 건 베낀 흔적이 아니다 — web4ai 는 DB 인스턴스를 따로 띄우지
# 않고, negosium 이 쓰는 postgres(5432) 안에 web4ai_db 라는 database 만 새로 만들어 쓴다
# (DECISIONS.md 3절). 그래서 아래 명령의 컨테이너 이름이 negosium-db 다.
# 최초 1회:
# docker exec -i -e PGPASSWORD=password negosium-db \
# (DECISIONS.md 3절). 그래서 아래 명령의 컨테이너 이름이 우리 것이 아니다.
# 최초 1회 — 컨테이너 이름은 환경마다 다르다(로컬 negosium-db · 킹서버 king_postgres_container):
# docker exec -i -e PGPASSWORD=password <postgres 컨테이너> \
# psql -h 127.0.0.1 -U postgres -d postgres -v ON_ERROR_STOP=1 < postgres-init/init-data/init.sql
x-common-env: &common-env
APP_ENV: local
PYTHONUNBUFFERED: "1"
# 이미지 안 config.local.toml 은 플레이스홀더다 — 아래 값이 없으면 접속·서명이 실패한다.
# 설정 파일은 없다 — 값은 전부 여기(=최상위 .env)서 온다. 비우면 config_models 의 기본값이다.
DB_HOST: ${DB_HOST:-host.docker.internal}
DB_PORT: ${DB_PORT:-5432}
DB_USER: ${DB_USER:-postgres}
@ -29,12 +29,12 @@ x-common-env: &common-env
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
services:
api:
solution-backend:
build:
context: .
dockerfile: solution/backend/Dockerfile
image: o2o-web4ai-backend
container_name: o2o-web4ai-api
container_name: o2o-web4ai-solution-backend
command: ["python", "web_main.py"]
env_file:
- .env
@ -45,7 +45,7 @@ services:
volumes:
- ./solution/site/payloads:/app/out/payloads
ports:
- "${API_BIND:-0.0.0.0}:9800:9800"
- "${API_BIND:-0.0.0.0}:${API_PORT:-9800}:9800"
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
@ -53,11 +53,12 @@ services:
driver: json-file
options: { max-size: "10m", max-file: "5" }
worker:
solution-worker:
build:
context: .
dockerfile: solution/backend/Dockerfile
image: o2o-web4ai-backend
container_name: o2o-web4ai-solution-worker
command: ["python", "worker_main.py"]
env_file:
- .env
@ -77,18 +78,21 @@ services:
- "host.docker.internal:host-gateway"
stop_grace_period: 300s
depends_on:
- api
- solution-backend
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 내부 운영 API(:9801). 진입점은 admin/backend/{main,app}.py 이고 도메인 코드는
# 어드민 API(:9801). 진입점은 admin/backend/{main,app}.py 이고 도메인 코드는
# solution/backend 것을 PYTHONPATH 로 그대로 쓴다 — place·fact 를 두 번 구현하지 않으면서
# 프로세스와 포트만 가른다. 여기 붙는 모든 엔드포인트는 role >= DEVELOPER 다.
api-admin:
admin-backend:
image: o2o-web4ai-backend
container_name: o2o-web4ai-api-admin
container_name: o2o-web4ai-admin-backend
# ★ 기본으로 뜨지 않는다. 어드민 화면을 안 쓰는 동안은 이 API 도 부를 사람이 없다.
# 켤 때: docker compose --profile admin up -d
profiles: ["admin"]
working_dir: /app/admin/backend
command: ["python", "main.py"]
env_file:
@ -109,20 +113,20 @@ services:
- ./solution/site/payloads:/app/out/payloads
ports:
# ★ 내부망에만 연다. 0.0.0.0 으로 열면 API 를 가른 의미가 없다.
- "${ADMIN_API_BIND:-127.0.0.1}:9801:9801"
- "${ADMIN_API_BIND:-127.0.0.1}:${ADMIN_API_PORT_PUBLIC:-9801}:9801"
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
- api
- solution-backend
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 사장님 앱(:3000) + 발행 사이트 프리렌더·정적서버(:3001, `/s/*` 프록시)
web:
solution-frontend:
image: node:24-alpine
container_name: o2o-web4ai-web
container_name: o2o-web4ai-solution-frontend
working_dir: /app
command:
- sh
@ -141,6 +145,10 @@ services:
# ★ 발행 호스트를 프론트 .env 에 따로 적지 않는다 — 루트 .env 의 SITE_PUBLIC_HOST 를
# 그대로 흘려보낸다. 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr}
# ★ 이 둘은 **브라우저가** 부르는 주소다. 컨테이너 안에서 보는 주소가 아니라
# 화면을 연 사람이 닿을 수 있는 주소여야 한다 — localhost 는 서버에 올리는 순간 틀린다.
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800}
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000}
volumes:
- ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json
@ -156,16 +164,17 @@ services:
# ★ 산출물은 named volume. 호스트 경로면 재배포로 코드를 갈아엎는 순간 전 사이트가 404 다.
- site-out:/app/solution/site/out
ports:
- "3000:3000"
- "${WEB_BIND:-0.0.0.0}:${WEB_PORT:-3000}:3000"
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 내부 운영 앱(:3002). 사장님 번들과 갈라 두는 것이 이 서비스의 존재 이유다.
admin:
admin-frontend:
image: node:24-alpine
container_name: o2o-web4ai-admin
container_name: o2o-web4ai-admin-frontend
profiles: ["admin"]
working_dir: /app
command:
- sh
@ -175,8 +184,11 @@ services:
[ -x node_modules/.bin/vite ] || npm install
exec npm run dev -w @o2o/admin
environment:
# ★ 내부 API(:9801)를 본다. :9800 을 보면 API 를 가른 의미가 없다.
# ★ 어드민 API(:9801)를 본다. :9800 을 보면 API 를 가른 의미가 없다.
VITE_API_BASE_URL: ${ADMIN_API_BASE_URL:-http://localhost:9801}
# 사장님 앱은 다른 오리진이라 절대 URL 로 연다(admin/frontend/src/lib/solutionUrl.ts).
# 이걸 안 넘기면 "빌더 열기" 가 서버에서 localhost:3000 을 가리켜 죽은 링크가 된다.
VITE_SOLUTION_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000}
volumes:
- ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json
@ -189,27 +201,29 @@ services:
- /app/admin/frontend/node_modules
ports:
# ★ 운영에서는 내부망에만 연다. 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- "${ADMIN_BIND:-127.0.0.1}:3002:3002"
- "${ADMIN_BIND:-127.0.0.1}:${ADMIN_PORT:-3002}:3002"
depends_on:
- web
- solution-frontend
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }
# 발행 사이트 정적 서빙. 굽는 쪽(web)과 분리 — 볼륨 하나를 사이에 두고 서로를 모른다.
nginx:
solution-site:
image: nginx:alpine
container_name: o2o-web4ai-nginx
container_name: o2o-web4ai-solution-site
volumes:
- site-out:/srv/sites:ro
# ★ 클론 직후: cp nginx/site.conf.example nginx/site.conf
# 파일이 없으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- ./nginx/site.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- "80:80"
# ★ :80 이 비어 있다는 보장이 없다(킹서버는 호스트 nginx 가 물고 있다).
# 앞단 프록시를 세울 거면 여기서 포트만 옮기고 프록시가 이쪽을 가리키게 한다.
- "${SITE_HTTP_BIND:-0.0.0.0}:${SITE_HTTP_PORT:-80}:80"
depends_on:
- web
- solution-frontend
restart: unless-stopped
logging:
driver: json-file

View File

@ -35,7 +35,7 @@ BUILD 잡 (worker) ─ services/build_service.py:99 run_build()
├ 1차 게이트 (DB 사실 기준) → publish_gate.evaluate()
├ site_payload.emit_payload() → out/payloads/<slug>.json ★ 백엔드의 유일한 산출물
│ ┌ [별도 컨테이너 o2o-web4ai-web]
│ ┌ [별도 컨테이너 o2o-web4ai-solution-frontend]
│ │ scripts/watch-payloads.mjs — payloads/ 2초 폴링, 바뀐 것만
│ │ └ node dist/prerender/prerender.js --payload=<file>
│ │ → out/s/<slug>/** + out/assets, out/fonts, out/robots.txt …
@ -59,7 +59,7 @@ Azure Blob 업로드 경로(`azure_static.py`)는 코드에 있고 동작하지
클라우드는 고도화 시점에 붙인다.
```
프리렌더(o2o-web4ai-web) ──쓴다──▶ named volume `site-out` ◀──읽는다(ro)── nginx(:80)
프리렌더(o2o-web4ai-solution-frontend) ──쓴다──▶ named volume `site-out` ◀──읽는다(ro)── nginx(:80)
```
굽는 쪽과 서빙하는 쪽이 **볼륨 하나를 사이에 두고 서로를 모른다.** 그래서 재배포로 코드를
@ -106,7 +106,7 @@ o2o-web4ai/
│ └─ shared/ frontend·site·백엔드 계약 (SitePayload · slug · 토큰)
├─ admin/ 우리 — 전체 사이트 운영
│ ├─ backend/ 내부 API 진입점(:9801). 도메인 코드는 solution/backend
│ ├─ backend/ 어드민 API 진입점(:9801). 도메인 코드는 solution/backend
│ └─ frontend/ 내부 운영 화면
├─ docs/ nginx/ postgres-init/ docker-compose.yml package.json(워크스페이스 루트)
@ -147,8 +147,8 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를
| | 포트 | 진입점 | 권한 |
|---|---|---|---|
| 사장님 API | 9800 | `solution/backend/web_main.py` | 엔드포인트별 |
| 내부 API | **9801** | `admin/backend/main.py``app.py` | **앱 전체 `role >= DEVELOPER`** |
| 솔루션 API | 9800 | `solution/backend/web_main.py` | 엔드포인트별 |
| 어드민 API | **9801** | `admin/backend/main.py``app.py` | **앱 전체 `role >= DEVELOPER`** |
`services`·`crud`·`models` 은 공유한다 — `admin/backend` 는 진입점 두 파일뿐이고,
도메인 코드는 `PYTHONPATH=/app/solution/backend` 로 그대로 import 한다. admin 화면이 부르는
@ -276,12 +276,12 @@ DB 에 HTML 컬럼은 없다 (`site_versions.snapshot` JSONB 가 원본).
| 컨테이너 | 무엇 | 포트 |
|---|---|---|
| `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` | 사장님 빌더 Vite + `watch-payloads` 프리렌더 + `serve-sites`(개발용 :3001) | 3000 |
| `o2o-web4ai-admin` | 내부 운영 화면 Vite | 3002 (기본 `127.0.0.1`) |
| `o2o-web4ai-nginx` | **발행 사이트 정적 서빙**`site-out` 볼륨을 읽기 전용으로 | 80 |
| `o2o-web4ai-solution-backend` | 솔루션 API (`solution/backend/web_main.py`) | 9800 |
| `o2o-web4ai-admin-frontend-backend` | 어드민 API (`admin/backend/main.py`) — 앱 전체 `role >= DEVELOPER` | 9801 (기본 `127.0.0.1`) |
| `o2o-web4ai-solution-worker` | 잡 러너 (BUILD·수집·생성) + 스케줄러 | — |
| `o2o-web4ai-solution-frontend` | 사장님 빌더 Vite + `watch-payloads` 프리렌더 + `serve-sites`(개발용 :3001) | 3000 |
| `o2o-web4ai-admin-frontend` | 내부 운영 화면 Vite | 3002 (기본 `127.0.0.1`) |
| `o2o-web4ai-solution-site` | **발행 사이트 정적 서빙**`site-out` 볼륨을 읽기 전용으로 | 80 |
DB(PostgreSQL)는 **compose 밖**이다 — 호스트에서 돌고 `host.docker.internal` 로 붙는다.
데이터 수명이 컨테이너 수명과 달라야 해서다.

View File

@ -15,7 +15,7 @@
|---|---|---|
| 야놀자 · 여기어때 | **불가** | 풀 브라우저 헤더로도 HTTP 403 + Cloudflare 챌린지. 뚫으려면 봇 탐지 우회가 필요한데 그건 영구 금지 영역이다. 법적으로도 야놀자 v 여기어때 = 형사 무죄(대법원 2022-05-12)지만 **민사 10억 배상 + 복제·저장 금지**(서울중앙지법 2021-08). 우리는 재게시까지 하므로 노출이 더 크다 |
| 네이버 플레이스 | **robots.txt 기준 불허** | `m.place.naver.com/robots.txt` = `User-agent: * / Disallow: /`. 현재 `naver_place` 어댑터는 위반 상태로 동작 중이다. 사장님 본인 업소 1건·사장님 동의·대량 DB 복제 아님이라 야놀자 사건과는 양상이 다르지만, **이 위에 제품을 세우지 않는다** — 대체 소스가 붙는 대로 발행 payload에서 빼고 대조용 참고값으로 내린다 |
| 카카오맵 | **불가** | 상세는 SPA 셸(3.6KB), 내부 API 406 차단 |
| 카카오맵 | **불가** | 상세는 SPA 셸(3.6KB), 어드민 API 406 차단 |
| **사장님이 확정한 자체 홈페이지** | **가능** | 사장님 동의 기반. → `StaticHtmlAdapter` 등록(2026-08-28) |
**추가 결론 (2026-08-31)** — TourAPI 어댑터를 붙여 같은 표본으로 대조한 결과
@ -115,7 +115,7 @@
| 테이블명 | 복수형 (`places`, `facts`) | 원본이 복수형(`companies`, `users`, `quotations`). 스펙 문서의 단수 표기는 엔티티 이름으로 읽었다 |
| `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 를 때리고 요금이 나간다 |
| 설정 주입 | 레포 최상위 `.env` + `pydantic-settings` | FastAPI 공식 방식(BaseSettings + env_file). 우선순위 = 실제 환경변수 > `.env` > 코드 기본값. toml 층은 없앴다(2026-09-01) — 키를 손으로 나열하다 `client_url` 이 빠져 배포 주소의 CORS 가 막혔다. **`APP_ENV=test``.env` 를 읽지 않는다** |
| 키 출처 | Perplexity·Gemini 는 `o2o-infinith-backend/.env` 값 재사용. **TourAPI 는 2026-08-31 활용신청 승인**, 카카오는 여전히 **미발급** | 카카오 키가 없으면 `kakao` 어댑터만 비활성이고 주변 정보 블록이 비어 뜬다 |
### 아직 테이블이 없는 것

View File

@ -77,9 +77,9 @@ site-out/
**규칙: `solution/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
```bash
docker compose restart web # 기동하며 전체 재굽기
docker compose logs -f web # "[watch] 기동" 배치가 끝날 때까지 대기
docker compose exec worker python scripts/republish_all.py
docker compose restart solution-frontend # 기동하며 전체 재굽기
docker compose logs -f solution-frontend # "[watch] 기동" 배치가 끝날 때까지 대기
docker compose exec solution-worker python scripts/republish_all.py
```
## 3. 서버에 올리는 순서 (지금 밟는 경로)
@ -105,13 +105,14 @@ docker compose exec worker python scripts/republish_all.py
다시 만들어야 한다.
### 1단계 — 서버에서 "굽기"만 재현 (Azure 끔)
배포 대상 서버(접속·경로·이미 물린 포트)는 [SERVERS.md](SERVERS.md) 가 단일 출처다.
```bash
# 서버에서
cp .env.example .env # DB_*, JWT_*, 외부 API 키 채우기
# AZURE_STORAGE_CONNECTION_STRING 은 비워 둔다 ← 이번 단계에서는 안 올린다
# INDEXNOW_KEY 도 비워 둔다 ← 없는 주소를 색인 통보하지 않는다
docker compose up -d
docker compose logs -f worker
docker compose logs -f solution-worker
```
확인: 사이트 1개 발행 → `curl -I http://<서버>:3000/s/<slug>` 200 ·
`out/s/<slug>/index.html` 생성 · `out/payloads/.status/<slug>.json``ok: true`.
@ -119,7 +120,7 @@ docker compose logs -f worker
여기서 막히면 Azure 문제가 아니다. **DB 연결 / payload 디렉토리 마운트 / node_modules** 셋 중 하나다.
### 2단계 — 정적 서빙을 nginx 로 교체 (구현됨)
`serve-sites.mjs` 는 개발용이다. 운영은 `o2o-web4ai-nginx` 컨테이너가 맡는다(`nginx/site.conf`).
`serve-sites.mjs` 는 개발용이다. 운영은 `o2o-web4ai-solution-site` 컨테이너가 맡는다(`nginx/site.conf`).
**산출물은 named volume `site-out` 에 있다.** 프리렌더가 쓰고 nginx·워커가 읽는다.
호스트 경로가 등장하지 않으므로 재배포로 코드를 갈아엎어도 사이트가 죽지 않는다.
@ -127,7 +128,7 @@ docker compose logs -f worker
```bash
docker compose up -d # nginx 포함 전부
docker compose exec nginx ls /usr/share/nginx/html/s # 들여다볼 때
docker compose exec solution-site ls /usr/share/nginx/html/s # 들여다볼 때
```
확인은 스크립트가 한다. 색인을 기다리지 않고 **지금 볼 수 있는 것만** 본다 —
@ -136,7 +137,7 @@ docker compose exec nginx ls /usr/share/nginx/html/s # 들여다볼 때
브라우저로는 절대 안 보인다).
```bash
docker compose exec worker python scripts/check_search_ready.py https://<도메인>
docker compose exec solution-worker python scripts/check_search_ready.py https://<도메인>
```
`/s/<slug>` (끝 슬래시 없음)이 열리는지도 이 스크립트가 본다 — 사장님이 주소창에 치는 형태가 그거다.
@ -161,8 +162,8 @@ AZURE_STORAGE_CONTAINER='$web'
AZURE_STORAGE_PREFIX=
```
```bash
docker compose exec worker python scripts/republish_all.py --dry-run # 대상 확인
docker compose exec worker python scripts/republish_all.py # 전체 업로드
docker compose exec solution-worker python scripts/republish_all.py --dry-run # 대상 확인
docker compose exec solution-worker python scripts/republish_all.py # 전체 업로드
```
확인: `https://<account>.z*.web.core.windows.net/s/<slug>`**CSS 까지 입혀서** 뜨는지.
스타일이 없으면 접두사/경로 문제다(2절 참고).
@ -171,12 +172,12 @@ docker compose exec worker python scripts/republish_all.py # 전체
1. DNS 를 Blob 정적 웹사이트(또는 앞단 CDN)로 연결
2. HTTPS 확인 — 커스텀 도메인 + TLS 는 Blob 단독으로는 안 되고 CDN/Front Door 가 필요하다
3. `SITE_PUBLIC_HOST` 가 실제 도메인과 같은지 재확인 → 다르면 고치고 **전체 재발행**
4. 그 다음에야 `INDEXNOW_KEY` 를 채운다. 백엔드와 `o2o-web4ai-web` 이 **같은 값**이어야 한다
4. 그 다음에야 `INDEXNOW_KEY` 를 채운다. 백엔드와 `o2o-web4ai-solution-frontend` 이 **같은 값**이어야 한다
(프리렌더가 루트에 `<key>.txt` 를 굽고 검색엔진이 대조한다 — 어긋나면 403)
5. `https://<도메인>/<key>.txt``https://<도메인>/robots.txt` 가 열리는지 확인
6. 구글은 IndexNow 미지원 → Search Console 에 `https://<도메인>/sitemap.xml` 수동 제출
## 5. 되돌리기
`out/` 은 재생성물이라 백업이 필요 없다. 문제가 생기면
`docker compose restart web` → 전체 재굽기 → `republish_all.py`.
`docker compose restart solution-frontend` → 전체 재굽기 → `republish_all.py`.
지켜야 할 건 **DB 와 `out/payloads/`** 뿐이다.

51
docs/DEVLOG.md Normal file
View File

@ -0,0 +1,51 @@
# 개발 일지
무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 **위에** 추가한다.
결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다.
---
## 2026-09-01 — 설정을 `.env` 하나로 모았다
**한 일**
- 백엔드 설정을 toml → `pydantic-settings`(FastAPI 공식 방식)로 옮겼다.
- `config_loader.py` · `config.local.toml.example` · `config.test.toml.example` 삭제.
- `server_configs.py` 107줄 → 26줄. `_apply_*_env_override` 함수 4개 제거.
- 호출부 21개 파일은 안 건드렸다 — 같은 이름을 그대로 내보낸다.
**왜**
키마다 `if os.environ.get(...)` 를 손으로 나열하는 구조였다. 하나 빠뜨리면 조용히 틀리는데,
실제로 `client_url` 이 빠져 있어 **배포 주소의 API 호출이 전부 CORS 로 막혔다**.
`BaseSettings` 는 필드를 선언하면 환경변수가 자동으로 들어와 이 사고가 구조적으로 안 난다.
**하는 김에 잡은 잠재 버그**
- `.env` 경로가 세 단계라 `solution/.env`(없는 파일)를 보고 있었다. 백엔드를 `solution/` 아래로
옮길 때 안 고쳐진 자리다. toml 이 값을 들고 있어 로컬에서 안 드러났고, 도커는 compose 가
환경변수를 직접 넣어 역시 멀쩡했다. toml 을 없앤 지금은 유일한 공급원이라 치명적이었다.
- 환경변수 이름을 `validation_alias` 로 못 박았다. 안 그러면 `port` 필드가 흔한 `PORT`
주워 먹어 엉뚱한 포트로 뜬다.
**결과** — 백엔드 설정 파일은 최상위 `.env` 하나뿐이다. → [DECISIONS.md](DECISIONS.md)
---
## 2026-08-31 — 킹서버 최초 배포
**한 일**
- `~/data2/o2o-web4ai` 에 배포. DB(`web4ai_db`) 생성 + `init.sql` 적용.
- 컴포즈 포트를 전부 `.env` 변수로 뽑았다. 로컬 기본값은 그대로다.
- `deploy.sh` · `log.sh` 추가.
**왜 포트를 뽑았나**
킹서버는 `:80` 을 호스트 nginx 가 이미 물고 있다. 사내망에 열려 있는 건 30xxx 대역뿐이라
그 안에서 자리를 잡아야 했다. → [SERVERS.md](SERVERS.md)
**밟은 함정**
- `PUBLIC_API_BASE_URL`**브라우저가** 부르는 주소다. `localhost` 로 두면 화면은 뜨고
API 만 죽는다 — 콘솔을 열기 전엔 안 보인다.
- 내부 화면의 "빌더 열기" 가 `VITE_SOLUTION_URL` 미주입으로 죽은 링크였다. 로컬에서는
기본값이 맞는 주소라 서버에 올리기 전까지 드러나지 않았다.
- `deploy.sh api` 는 worker·api-admin 도 함께 갈아끼운다. 셋이 이미지 한 벌을 나눠 쓰는데
하나만 바꾸면 옛 코드로 도는 컨테이너가 남고, `ps` 로는 셋 다 살아 있어 구분이 안 된다.
**남은 것** — `w4ai.o2o.kr` DNS + 앞단(59.14.81.3) 포워딩. 서버에 sudo 가 없어 인프라 몫이다.

119
docs/SERVERS.md Normal file
View File

@ -0,0 +1,119 @@
# 킹서버 — 배포 대상 정의
배포 **절차**는 [DEPLOY.md](DEPLOY.md) 3절이다. 이 문서는 그 절차가 "서버에서" 라고만 부르는
**그 서버가 무엇인지**만 적는다. 값은 2026-08-31 에 직접 붙어서 확인한 것이다.
## 접속
```bash
ssh King_admin # ~/.ssh/config 에 정의됨
```
| | |
|---|---|
| 호스트명 | `king` (`172.30.1.36`) — **사설 IP다. 직접 못 닿는다** |
| 계정 | `o2oadmin` |
| 경유 | `ProxyJump Confluence` = `59.14.81.3:14444` |
`~/.ssh/config` 는 레포 밖이다. 새로 합류하면 아래를 직접 넣어야 붙는다.
```
Host King_admin
HostName 172.30.1.36
User o2oadmin
ProxyJump Confluence
Host Confluence
HostName 59.14.81.3
Port 14444
User o2oadmin
```
## 무엇이 올라가 있나
Ubuntu 18.04.6 LTS · 24 core · RAM 125G · Docker 24.0.2 · Docker Compose v2.20.3.
컨테이너 34개가 이미 돈다 (negosium · iquote · triple-pick · infinith · gitea · persona 등).
**우리만 쓰는 서버가 아니다** — 포트와 디스크를 남의 것과 나눠 쓴다.
## 어디에 두나 — `/home/o2oadmin/data2/o2o-web4ai`
레포는 홈이 아니라 **`data2` 밑**에 둔다. 홈이 있는 루트 디스크와 다른 물리 디스크다.
```
/ 1.8T 중 228G 남음 (87% 사용) ← 여기에 두면 곧 찬다
/mnt/data2 3.6T 중 2.7T 남음 (22% 사용) ← 다른 o2o 프로젝트가 전부 여기 있다
```
`o2o-negosium` · `o2o-iquote` · `o2o-triple-pick` 이 모두 `/home/o2oadmin/data2/<레포명>` 이다.
같은 규약을 따른다. (`git remote` 는 `https://gitea.o2o.kr/castad/o2o-web4ai.git` — gitea 도
이 서버의 컨테이너다.)
## 포트 — 이 서버에서 우리가 잡은 자리
**`:80` 은 호스트 nginx 가 이미 물고 있다**(bible-chatbot·o2sound-voucher 를 라우팅 중).
그래서 컴포즈의 포트를 전부 `.env` 로 뽑아 두고, 킹서버에서는 30xxx 대역을 쓴다.
로컬 기본값은 그대로다 — `.env` 를 안 채우면 예전과 똑같이 뜬다.
| 서비스 | 컨테이너 포트 | 킹서버 | bind | 누가 보나 |
|---|---|---|---|---|
| `solution-site` (발행 사이트) | 80 | **30030** | 0.0.0.0 | 방문자 |
| `solution-frontend` (사장님 앱) | 3000 | **30031** | 0.0.0.0 | 사장님 |
| `solution-backend` (솔루션 API) | 9800 | **30032** | 0.0.0.0 | 사장님 브라우저 |
| `solution-worker` (잡 러너) | — | — | — | — |
| `admin-frontend` (어드민 화면) | 3002 | 3002 | **127.0.0.1** | 우리 |
| `admin-backend` (어드민 API) | 9801 | 9801 | **127.0.0.1** | 우리 |
★ 어드민 둘은 **기본 기동에서 빠져 있다**(compose 프로필). 켤 때는
`docker compose --profile admin up -d`.
내부 둘은 로컬호스트에만 연다 — 포트를 가른 이유가 그거다([AGENTS.md](../AGENTS.md)).
밖에서 볼 땐 터널을 판다:
```bash
ssh -N -L 3002:127.0.0.1:3002 -L 9801:127.0.0.1:9801 King_admin
# → http://localhost:3002
```
**30xxx 는 사내망(172.30.1.x)에서만 닿는다.** 밖에서는 안 열린다 —
`59.14.81.3:30010` 같은 이웃 프로젝트 포트도 바깥에서 막혀 있는 걸 확인했다.
공개하려면 앞단(59.14.81.3)에 포워딩/프록시를 걸어야 하고, 그건 이 레포 밖이다.
**`PUBLIC_API_BASE_URL` 은 브라우저가 부르는 주소다.** 컨테이너 안 주소가 아니다.
비워 두면 `http://localhost:9800` 이라 **서버에 올리는 순간 틀린다**
화면은 뜨는데 API 만 안 되고, 콘솔을 열기 전에는 안 보인다.
## 배포 · 로그
레포 루트에 스크립트 두 개가 있다. 서버에서 실행한다.
```bash
cd ~/data2/o2o-web4ai
./deploy.sh # 전체 (git pull → build → up -d)
./deploy.sh api # 그 서비스만
./log.sh # 1=전체, 2번부터 개별 컨테이너
./log.sh 1 # 메뉴 없이 바로
```
`deploy.sh api` 는 worker·api-admin 도 함께 갈아끼운다. 셋이 이미지 한 벌을 나눠 쓰기 때문이다 —
안 그러면 옛 코드로 도는 컨테이너가 남는데 셋 다 "살아 있음" 이라 눈으로는 구분이 안 된다.
★ 서버에 gitea 자격증명이 없어 `git pull` 이 실패한다. 밖에서 밀어넣고 `./deploy.sh --no-pull`
로 돌리거나, gitea 에 배포키를 등록해 원격을 SSH 로 바꾼다.
## DB
호스트에 PostgreSQL 15(pgvecto)가 `king_postgres_container` 로 떠 있고 `5432` 가 호스트에 열려 있다.
컴포즈가 `DB_HOST` 기본값을 `host.docker.internal` 로 두고 `extra_hosts: host-gateway`
붙여 두었으므로 **컴포즈를 고치지 않고 그대로 닿는다.** DB 는 만들어야 한다 —
`postgres-init/init-data/init.sql` 한 벌이 스키마 전부다.
## 공개 주소 — 배포 전에 먼저 풀 것
**`w4ai.o2o.kr` 은 지금 DNS 레코드가 없다.** (`dig +short w4ai.o2o.kr` → 빈 응답)
이 값이 `SITE_PUBLIC_HOST` 기본값이고 canonical·og:url·sitemap·IndexNow 에 전부 들어간다.
DNS 를 붙이기 전에 발행하면 **존재하지 않는 주소를 색인 통보한다** — 발행은 성공하므로
아무도 눈치채지 못하는 종류의 오작동이다. [DEPLOY.md 0단계](DEPLOY.md)가 이걸 말한다.
바깥에서 이 서버로 들어오는 입구는 **`59.14.81.3`** 이다 — `gitea.o2o.kr` 이 그 IP로 풀리고
HTTPS 200 이 뜬다. 즉 앞단에 포워딩/리버스프록시가 있고, 우리 주소도 거기에 태워야 한다.
그 앞단은 이 레포 밖이라 인프라 담당에게 요청해야 한다.

87
log.sh Executable file
View File

@ -0,0 +1,87 @@
#!/usr/bin/env bash
# 로그 보기.
#
# ./log.sh 메뉴에서 고른다
# ./log.sh 1 1번 = 전체 로그
# ./log.sh 4 4번 컨테이너
# ./log.sh solution-backend 이름으로 바로
#
# -n <줄> 처음에 보여줄 줄 수 (기본 200)
# --no-f 따라가지 않고 지금까지만 찍고 끝낸다
set -euo pipefail
cd "$(dirname "$0")"
TAIL=200
FOLLOW=1
PICK=""
while [ $# -gt 0 ]; do
case "$1" in
-n) TAIL="$2"; shift ;;
--no-f|--no-follow) FOLLOW=0 ;;
-h|--help) awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$0"; exit 0 ;;
-*) echo "모르는 옵션: $1" >&2; exit 2 ;;
*) PICK="$1" ;;
esac
shift
done
# 번호가 흔들리면 손이 기억한 번호가 다른 컨테이너를 연다 — 순서를 코드로 못 박는다.
ORDER=(solution-backend solution-worker solution-frontend solution-site admin-backend admin-frontend)
AVAILABLE=$(docker compose config --services)
SVCS=()
for s in "${ORDER[@]}"; do grep -qx "$s" <<<"$AVAILABLE" && SVCS+=("$s"); done
# compose 에 서비스가 늘면 뒤에 붙는다(빠뜨리지 않으려고).
while read -r s; do
[ -n "$s" ] || continue
printf '%s\n' "${SVCS[@]}" | grep -qx "$s" || SVCS+=("$s")
done <<<"$AVAILABLE"
# 살아 있는지 한눈에 — 죽은 걸 고르고 "로그가 안 나온다" 하는 자리를 없앤다.
# ★ --format '{{.Service}}' 은 쓰지 않는다. compose v2.20 이 커스텀 템플릿을 파싱하지 못해
# 전부 "미기동" 으로 보인다(명령은 성공해서 틀린 줄 모른다).
RUNNING=$(docker compose ps --services --filter status=running 2>/dev/null || true)
status() {
grep -qx "$1" <<<"$RUNNING" && printf '실행중' || printf '멈춤'
}
menu() {
echo
echo " o2o-web4ai 로그"
echo " ─────────────────────────────"
printf " 1) %-18s %s\n" "전체" ""
local i=2
for s in "${SVCS[@]}"; do
printf " %2d) %-18s %s\n" "$i" "$s" "[$(status "$s")]"
i=$((i + 1))
done
echo " q) 나가기"
echo
}
resolve() { # 번호 또는 이름 → docker compose logs 인자
local pick="$1"
if [ "$pick" = "1" ]; then TARGET=(); return; fi
if [[ "$pick" =~ ^[0-9]+$ ]]; then
local idx=$((pick - 2))
[ "$idx" -ge 0 ] && [ "$idx" -lt "${#SVCS[@]}" ] || { echo "그런 번호가 없다: $pick" >&2; return 1; }
TARGET=("${SVCS[$idx]}"); return
fi
local name="${pick#o2o-web4ai-}"
printf '%s\n' "${SVCS[@]}" | grep -qx "$name" || { echo "그런 컨테이너가 없다: $pick" >&2; return 1; }
TARGET=("$name")
}
if [ -z "$PICK" ]; then
menu
read -rp " 번호: " PICK
[ "$PICK" = "q" ] && exit 0
fi
TARGET=()
resolve "$PICK" || exit 2
ARGS=(--tail="$TAIL")
[ "$FOLLOW" = 1 ] && ARGS+=(-f)
echo "▶ docker compose logs ${ARGS[*]} ${TARGET[*]:-(전체)}"
exec docker compose logs "${ARGS[@]}" ${TARGET+"${TARGET[@]}"}

View File

@ -20,8 +20,6 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY solution/backend ./solution/backend
COPY admin/backend ./admin/backend
# 시크릿 든 config.local.toml 은 .dockerignore 로 제외됨 → example(플레이스홀더)로 대체.
RUN cp solution/backend/config/config.local.toml.example solution/backend/config/config.local.toml
ENV APP_ENV=local
# ★ 내부 API(admin/backend)가 common·router·services 를 그대로 import 하는 경로.

View File

@ -9,8 +9,8 @@
**수집 → 생성 → 빌드 → 발행 파이프라인이 API 로 다 열렸다.** 프론트(`solution/frontend`)가 이 API 를 붙여 쓴다.
**코드는 한 벌인데 진입점이 둘이다**사장님 API `web_main.py`(:9800, 엔드포인트별 권한)와
내부 API `admin/backend/main.py`(:9801, 앱 전체 `role >= DEVELOPER`). 같은 router 객체를 다시
**코드는 한 벌인데 진입점이 둘이다**솔루션 API `web_main.py`(:9800, 엔드포인트별 권한)와
어드민 API `admin/backend/main.py`(:9801, 앱 전체 `role >= DEVELOPER`). 같은 router 객체를 다시
마운트하고 앱 단위로 권한만 덧건다. 왜 경로 접두어가 아니라 포트인지는
[../../docs/ARCHITECTURE.md 4절](../../docs/ARCHITECTURE.md).
@ -88,9 +88,9 @@ o2o-web4ai/
├── .env.example # 외부 API 키·DB 접속 주입 템플릿 (cp .env.example .env)
├── postgres-init/
│ └── init-data/init.sql # 스키마 DDL 한 벌 (재실행 안전. 누적 ALTER 파일은 없다)
├── admin/backend/ # 내부 API 진입점(:9801). 도메인 코드는 아래를 그대로 import 한다
├── admin/backend/ # 어드민 API 진입점(:9801). 도메인 코드는 아래를 그대로 import 한다
└── solution/backend/
├── web_main.py # 사장님 API 진입점 :9800 (uvicorn)
├── web_main.py # 솔루션 API 진입점 :9800 (uvicorn)
├── worker_main.py # 잡 러너 + 스케줄러
├── config/ # 환경설정 (APP_ENV 별 toml 로드)
├── conftest.py, tests/ # pytest (test DB 자동 create/drop) — 아래 '테스트'
@ -196,9 +196,9 @@ POST /v1/place/{id}/site/slug {slug} → sites.domain 에 저장(사
| 무엇 | 어디 | 커밋 |
|---|---|---|
| DB 접속 · JWT · 포트 | `solution/backend/config/config.local.toml` | ✗ (`*.toml` ignore) |
| DB 접속 · JWT · 포트 | 레포 최상위 `.env` | ✗ (ignore) |
| 외부 API 키 | 레포 최상위 `.env` | ✗ |
| 템플릿 | `config/config.local.toml.example` · `.env.example` | ✓ |
| 템플릿 | `.env.example` | ✓ |
우선순위: **실제 환경변수(docker-compose) > `.env` > `config.{APP_ENV}.toml`**
@ -219,7 +219,7 @@ psql -h 127.0.0.1 -p 5432 -U postgres -f ../../postgres-init/init-data/init.sql
cd ../.. # 레포 루트
cp .env.example .env # 최초 1회, 외부 API 키 채우기
cd solution/backend
cp config/config.local.toml.example config/config.local.toml # 최초 1회, DB·JWT 채우기
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
@ -244,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/config.test.toml`([예제](config/config.test.toml.example)) 의 **`web4ai_test_db`** 사용(dev DB 와 완전 분리).
- `APP_ENV``test` 로 자동 설정 → DB 이름 기본값이 **`web4ai_test_db`** 로 갈린다(dev DB 와 완전 분리, `config_models.py`).
- **세션 시작 시 test DB 를 새로 만들고(CREATE), 끝나면 내린다(DROP).** 매번 현재 모델로 새로 빌드돼 스키마가 낡을 일이 없다.
- 테이블은 `create_all` 로 자동 생성, 매 테스트 전 `TRUNCATE` 로 비워 격리.
- 안전가드: 이름에 `test` 없는 DB 는 만들지도 지우지도 않는다(실 DB 보호).

View File

@ -1,52 +0,0 @@
# 복사해서 사용: cp config.local.toml.example config.local.toml
# 실제 config.local.toml 은 시크릿 포함이라 커밋하지 않는다(.gitignore: *.toml).
# 모든 서버는 APP_ENV=local 로 띄우며 이 파일을 읽는다.
[WebServerConfig]
server_name = "Web4aiServer"
port = 9800
process_count = 1
is_ssl = false
is_test = true
# 관리자 프론트 오리진 — CORS 허용. 쉼표로 여러 개.
# vite 는 3000 이 쓰이고 있으면 3001, 3002… 로 옮겨 뜨므로 개발 포트 대역을 함께 적어 둔다.
# 운영에서는 실제 도메인 하나만 남긴다.
client_url = "http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3003,http://localhost:3004,http://localhost:3005"
landing_url = "" # 랜딩(마케팅 사이트). 없으면 빈값
[LogConfig]
print_console = true
log_level = "debug"
# DB Read/Write 분리. 도커 실행 시 host 는 docker-compose 의 DB_HOST 로 override.
# 관리형 DB(RDS/Aurora/Azure)는 host 에 엔드포인트, sslmode="require".
[MainDBConfig]
db_type = "postgresql"
name = "web4ai_db"
write_host = "127.0.0.1"
write_port = 5432
write_id = "<DB_USER>"
write_pw = "<DB_PASSWORD>"
read_host = "127.0.0.1"
read_port = 5432
read_id = "<DB_USER>"
read_pw = "<DB_PASSWORD>"
show_log = false
pool_size = 10
max_overflow = 20
sslmode = "" # 로컬: "" / 관리형 DB: "require"|"verify-ca"|"verify-full"
[JwtToken]
access_key = "<JWT_ACCESS_SECRET>"
refresh_key = "<JWT_REFRESH_SECRET>"
access_expire_min = 30
refresh_expire_day = 7
# 수집 파이프라인 외부 API 키. 빈값이면 해당 어댑터 비활성(서버는 그대로 뜬다).
# 배포에서 환경변수로만 주입하려면 여기는 비우고 PERPLEXITY_API_KEY 등을 env 로 넘긴다.
[ExternalApiConfig]
perplexity_api_key = "" # 채널 URL 발견
kakao_rest_api_key = "" # 동일 업소 검증(미발급)
naver_client_id = "" # 동일 업소 검증 — 네이버 지역검색
naver_client_secret = ""
gemini_api_key = "" # 사진 분류 · 카피 작성
tour_api_key = "" # 공공데이터포털 전국문화축제표준데이터 일반 인증키

View File

@ -1,51 +0,0 @@
# 복사해서 사용: cp config.test.toml.example config.test.toml
# 실제 config.test.toml 은 커밋하지 않는다(.gitignore: *.toml).
#
# ★ 이 파일이 없으면 `.venv/bin/pytest` 가 conftest import 단계에서 죽는다
# (server_configs 가 config.<APP_ENV>.toml 을 읽는다). 그래서 템플릿을 둔다.
# ★ DB 이름을 dev 와 절대 같게 두지 말 것 — 픽스처가 TRUNCATE 를 돌린다.
# conftest 의 안전가드가 이름을 확인하지만, 여기서부터 갈라 두는 게 먼저다.
[WebServerConfig]
server_name = "O2oSiteServerTest"
port = 9800
process_count = 1
is_ssl = false
is_test = true
client_url = "http://localhost:3000"
landing_url = ""
[LogConfig]
print_console = false
log_level = "warning"
[MainDBConfig]
db_type = "postgresql"
name = "web4ai_test_db" # ★ dev(web4ai_db)와 다른 DB. 픽스처가 매번 지웠다 만든다
write_host = "127.0.0.1"
write_port = 5432
write_id = "postgres"
write_pw = "password"
read_host = "127.0.0.1"
read_port = 5432
read_id = "postgres"
read_pw = "password"
show_log = false
pool_size = 5
max_overflow = 10
sslmode = ""
[JwtToken]
access_key = "test-access-key-not-a-secret"
refresh_key = "test-refresh-key-not-a-secret"
access_expire_min = 30
refresh_expire_day = 7
# ★ 전부 빈값으로 둔다. APP_ENV=test 는 .env 를 읽지 않는데(실키가 테스트로 새는 경로 차단),
# 여기에 실키를 적으면 그 차단을 우회해 외부 API 요금이 나간다.
[ExternalApiConfig]
perplexity_api_key = ""
kakao_rest_api_key = ""
naver_client_id = ""
naver_client_secret = ""
gemini_api_key = ""
tour_api_key = ""

View File

@ -1,37 +0,0 @@
import tomllib
from typing import Optional, Type, Dict, TypeVar
from pydantic import BaseModel
class ConfigModel(BaseModel):
pass
# APP_ENV
# local : 로컬 환경(개인 pc)
# dev : 개발환경 (사내 pc)
# prod : 서비스 환경 (클라우드 서버)
#
# 실행시 환경변수 설정
# linux : export APP_ENV=dev
# window : set APP_ENV=dev
class Configs:
ConfigType = TypeVar("ConfigType", bound=ConfigModel)
def __init__(self, file_path: str):
self._settings: Dict[Type["Configs.ConfigType"], "Configs.ConfigType"] = self._load_settings_from_toml(file_path)
def _load_settings_from_toml(self, file_path: str) -> Dict[Type[ConfigType], ConfigType]:
with open(file_path, "rb") as f:
toml_content = tomllib.load(f)
config_subclasses = ConfigModel.__subclasses__()
configs = {
config_class: config_class.model_validate(toml_content[config_class.__name__])
for config_class in config_subclasses
if config_class.__name__ in toml_content
}
return configs
def get(self, config_class: Type[ConfigType]) -> Optional[ConfigType]:
return self._settings.get(config_class)

View File

@ -1,66 +1,146 @@
from config.config_loader import ConfigModel
"""설정 모델 — 값은 전부 환경변수(최상위 .env 포함)에서 온다.
환경변수 이름은 validation_alias 박는다. 필드명만 두면 `port` 흔한 `PORT`
주워 먹어 엉뚱한 포트로 뜬다.
"""
from functools import lru_cache
from typing import Optional
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
import os
# 레포 최상위 .env. 여기서 네 단계 위다 — 세 단계로 두면 solution/.env(없는 파일)를 본다.
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
_DOTENV = os.path.join(_REPO_ROOT, ".env")
APP_ENV = os.environ.get("APP_ENV", "local")
# ★ APP_ENV=test 면 .env 를 읽지 않는다. 실키가 새면 테스트가 실제 외부 API 를 때린다.
_ENV_FILE = None if APP_ENV == "test" else _DOTENV
# ★ 테스트는 별도 DB. conftest 가 "이름에 test 없으면 중단" 으로 dev DB 를 지킨다.
_DEFAULT_DB_NAME = "web4ai_test_db" if APP_ENV == "test" else "web4ai_db"
_BASE = SettingsConfigDict(env_file=_ENV_FILE, env_file_encoding="utf-8", extra="ignore", case_sensitive=False)
class WebServerConfig(ConfigModel):
server_name: str = ""
port: int = 0
process_count: int = 1
is_ssl: bool = False
is_test: bool = False
client_url: str = "" # 관리자 프론트 오리진. CORS 허용 대상
landing_url: str = "" # 랜딩(마케팅 사이트) 오리진. 빈값=미허용
class WebServerConfig(BaseSettings):
model_config = _BASE
server_name: str = Field("Web4aiServer", validation_alias="WEB_SERVER_NAME")
port: int = Field(9800, validation_alias="WEB_SERVER_PORT")
process_count: int = Field(1, validation_alias="WEB_PROCESS_COUNT")
is_ssl: bool = Field(False, validation_alias="WEB_IS_SSL")
is_test: bool = Field(False, validation_alias="WEB_IS_TEST")
# CORS 허용 오리진(쉼표로 여럿). vite 는 3000 이 막히면 3001, 3002… 로 옮겨 뜬다.
client_url: str = Field(
"http://localhost:3000,http://localhost:3001,http://localhost:3002,"
"http://localhost:3003,http://localhost:3004,http://localhost:3005",
validation_alias="CLIENT_URL",
)
landing_url: str = Field("", validation_alias="LANDING_URL")
class LogConfig(ConfigModel):
print_console: bool = True
log_level: str = "debug"
class LogConfig(BaseSettings):
model_config = _BASE
print_console: bool = Field(True, validation_alias="LOG_PRINT_CONSOLE")
log_level: str = Field("debug", validation_alias="LOG_LEVEL")
# DB Read/Write 분리 설정.
# 하나의 논리 DB 에 대해 write(주) / read(복제) 접속 정보를 각각 가진다.
class MainDBConfig(ConfigModel):
db_type: str = "postgresql"
name: str = ""
write_host: str = ""
write_port: int = 5432
write_id: str = ""
write_pw: str = ""
read_host: str = ""
read_port: int = 5432
read_id: str = ""
read_pw: str = ""
show_log: bool = False
# 커넥션 풀 사이징. 실제 동시 커넥션 상한 = (pool_size + max_overflow) x 엔진수(R/W=2) x 워커수.
# PostgreSQL max_connections 를 넘지 않도록 설정해야 한다. (예: 10+20=30 x 2 x 5워커 = 300)
pool_size: int = 10
max_overflow: int = 20
# SSL/TLS 모드: ""/"disable"=미사용(로컬), "require"/"verify-ca"/"verify-full"=관리형 DB(RDS/Aurora/Azure).
sslmode: str = ""
class MainDBConfig(BaseSettings):
"""DB read/write 분리. 읽기 접속을 안 주면 쓰기와 같은 곳을 본다(복제 없는 환경이 기본)."""
model_config = _BASE
db_type: str = Field("postgresql", validation_alias="DB_TYPE")
name: str = Field(_DEFAULT_DB_NAME, validation_alias="DB_NAME")
write_host: str = Field("127.0.0.1", validation_alias="DB_HOST")
write_port: int = Field(5432, validation_alias="DB_PORT")
write_id: str = Field("postgres", validation_alias="DB_USER")
write_pw: str = Field("", validation_alias="DB_PASSWORD")
read_host: Optional[str] = Field(None, validation_alias="DB_READ_HOST")
read_port: Optional[int] = Field(None, validation_alias="DB_READ_PORT")
read_id: Optional[str] = Field(None, validation_alias="DB_READ_USER")
read_pw: Optional[str] = Field(None, validation_alias="DB_READ_PASSWORD")
show_log: bool = Field(False, validation_alias="DB_SHOW_LOG")
# 동시 커넥션 상한 = (pool_size + max_overflow) x 엔진수(R/W=2) x 워커수.
# PostgreSQL max_connections 를 넘기면 안 된다.
pool_size: int = Field(10, validation_alias="DB_POOL_SIZE")
max_overflow: int = Field(20, validation_alias="DB_MAX_OVERFLOW")
# ""/"disable"=로컬 · "require"|"verify-ca"|"verify-full"=관리형 DB
sslmode: str = Field("", validation_alias="DB_SSLMODE")
@model_validator(mode="after")
def _read_falls_back_to_write(self):
if self.read_host is None:
self.read_host = self.write_host
if self.read_port is None:
self.read_port = self.write_port
if self.read_id is None:
self.read_id = self.write_id
if self.read_pw is None:
self.read_pw = self.write_pw
return self
class JwtToken(ConfigModel):
access_key: str = ""
refresh_key: str = ""
access_expire_min: int = 30
refresh_expire_day: int = 7
class JwtToken(BaseSettings):
model_config = _BASE
access_key: str = Field("", validation_alias="JWT_ACCESS_SECRET")
refresh_key: str = Field("", validation_alias="JWT_REFRESH_SECRET")
access_expire_min: int = Field(30, validation_alias="JWT_ACCESS_EXPIRE_MIN")
refresh_expire_day: int = Field(7, validation_alias="JWT_REFRESH_EXPIRE_DAY")
# 외부 API 키. 수집 파이프라인(Perplexity → 카카오 로컬 → 크롤링 → Gemini)이 쓴다.
# 키가 비면 해당 어댑터는 비활성 — 서버는 그대로 뜬다(부팅이 외부 계약에 묶이면 안 된다).
class ExternalApiConfig(ConfigModel):
perplexity_api_key: str = "" # 채널 URL 발견 (api.perplexity.ai)
# 동일 업소 검증 — 두 소스 중 설정된 쪽을 쓴다(둘 다 있으면 카카오 우선).
# 카카오: 15건 · 전화번호 O · 고유 place id O · 행정구역 코드 O → 판정 근거가 강하다
# 네이버: 5건 · 전화번호 X · 고유 id 불확실 · 행정구역 코드 X → 그만큼 AMBIGUOUS 가 늘어난다
kakao_rest_api_key: str = "" # dapi.kakao.com (미발급)
naver_client_id: str = "" # openapi.naver.com 지역검색
naver_client_secret: str = ""
gemini_api_key: str = "" # 사진 분류 + 카피 작성 (Google AI Studio)
# 비전 모델. 사진 1장 ≈ 1,200 토큰이라 사업장 1건(50장)에 3.7-flash $0.045 / 2.5-flash $0.018.
# 기본을 3.7 로 두는 이유: 라벨이 틀리면 사람 확인 큐 비용이 모델 값 차이보다 크다.
gemini_vision_model: str = "gemini-3.7-flash"
# 소개문·FAQ 생성 모델. 비전과 따로 둔다 — 텍스트는 토큰이 적어 상위 모델을 써도 싸다.
gemini_text_model: str = "gemini-3.7-flash"
# ★ 이 값 미만이면 자동 반영하지 않고 사람 확인 큐(PENDING_REVIEW)에 남긴다. 운영하며 조정한다.
vision_confidence_threshold: float = 0.7
tour_api_key: str = "" # 공공데이터포털 일반 인증키(전국문화축제표준데이터)
class ExternalApiConfig(BaseSettings):
"""키가 비면 그 어댑터만 비활성이다 — 부팅이 외부 계약에 묶이면 안 된다."""
model_config = _BASE
perplexity_api_key: str = Field("", validation_alias="PERPLEXITY_API_KEY")
# 동일 업소 검증 — 둘 다 있으면 카카오 우선.
# 카카오: 15건 · 전화번호 O · 고유 id O · 행정구역 코드 O
# 네이버: 5건 · 전화번호 X · 고유 id 불확실 · 행정구역 코드 X → AMBIGUOUS 가 는다
kakao_rest_api_key: str = Field("", validation_alias="KAKAO_REST_API_KEY")
naver_client_id: str = Field("", validation_alias="NAVER_CLIENT_ID")
naver_client_secret: str = Field("", validation_alias="NAVER_CLIENT_SECRET")
gemini_api_key: str = Field("", validation_alias="GEMINI_API_KEY")
# 3.7 기본: 라벨이 틀리면 사람 확인 큐 비용이 모델 값 차이(1건 $0.045 vs $0.018)보다 크다.
gemini_vision_model: str = Field("gemini-3.7-flash", validation_alias="GEMINI_VISION_MODEL")
gemini_text_model: str = Field("gemini-3.7-flash", validation_alias="GEMINI_TEXT_MODEL")
# 이 값 미만이면 자동 반영하지 않고 사람 확인 큐(PENDING_REVIEW)에 남긴다.
vision_confidence_threshold: float = Field(0.7, validation_alias="VISION_CONFIDENCE_THRESHOLD")
tour_api_key: str = Field("", validation_alias="TOUR_API_KEY")
# .env 를 요청마다 다시 읽지 않는다. 새 코드는 Depends(get_*) 로 주입받는다.
@lru_cache
def get_web_server_config() -> WebServerConfig:
return WebServerConfig()
@lru_cache
def get_log_config() -> LogConfig:
return LogConfig()
@lru_cache
def get_main_db_config() -> MainDBConfig:
return MainDBConfig()
@lru_cache
def get_jwt_token_config() -> JwtToken:
return JwtToken()
@lru_cache
def get_external_api_config() -> ExternalApiConfig:
return ExternalApiConfig()

View File

@ -1,107 +1,26 @@
import os
"""설정 싱글턴 — 호출부 21개 파일이 이 이름들을 가져다 쓴다."""
from dotenv import load_dotenv
from config.config_models import (
ExternalApiConfig,
JwtToken,
LogConfig,
MainDBConfig,
WebServerConfig,
get_external_api_config,
get_jwt_token_config,
get_log_config,
get_main_db_config,
get_web_server_config,
)
from config.config_loader import Configs
from config.config_models import WebServerConfig, LogConfig, MainDBConfig, JwtToken, ExternalApiConfig
web_server_config: WebServerConfig = get_web_server_config()
log_config: LogConfig = get_log_config()
main_db_config: MainDBConfig = get_main_db_config()
jwt_token_config: JwtToken = get_jwt_token_config()
external_api_config: ExternalApiConfig = get_external_api_config()
# 레포 최상위 .env 를 환경변수로 올린다 (외부 API 키·DB 접속의 배포용 주입 경로).
# override=False: 이미 설정된 실제 환경변수(docker-compose 등)가 항상 이긴다.
# 우선순위 = 실제 환경변수 > .env > config.{APP_ENV}.toml
#
# ★ 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")
if os.environ.get("APP_ENV") != "test":
load_dotenv(_DOTENV_PATH, override=False)
# 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경.
APP_ENV = os.environ.get("APP_ENV", "local")
_config_dir = os.path.dirname(__file__)
_config_file = os.path.join(_config_dir, f"config.{APP_ENV}.toml")
# 운영 전제: 항상 APP_ENV=local 로 띄운다 → config.local.toml 사용 (test/docker 도 local 로 실행).
if not os.path.exists(_config_file):
raise FileNotFoundError(f"설정 파일이 없습니다: {_config_file} (APP_ENV={APP_ENV}). APP_ENV=local 로 실행하세요.")
configs = Configs(_config_file)
web_server_config: WebServerConfig = configs.get(WebServerConfig)
log_config: LogConfig = configs.get(LogConfig)
main_db_config: MainDBConfig = configs.get(MainDBConfig)
jwt_token_config: JwtToken = configs.get(JwtToken)
# [ExternalApiConfig] 섹션이 없는 toml 에서도 죽지 않게 기본값 폴백(전 키 빈 값 → 해당 어댑터 비활성).
external_api_config: ExternalApiConfig = configs.get(ExternalApiConfig) or ExternalApiConfig()
# DB 접속 env override (config.local.toml 유지, 도커에서 host 만 교체). 로컬은 env 미설정 → toml 그대로.
def _apply_db_env_override(cfg: MainDBConfig):
h = os.environ.get("DB_HOST")
if h:
cfg.write_host = cfg.read_host = h
if os.environ.get("DB_PORT"):
cfg.write_port = cfg.read_port = int(os.environ["DB_PORT"])
if os.environ.get("DB_USER"):
cfg.write_id = cfg.read_id = os.environ["DB_USER"]
if os.environ.get("DB_PASSWORD"):
cfg.write_pw = cfg.read_pw = os.environ["DB_PASSWORD"]
if os.environ.get("DB_NAME"):
cfg.name = os.environ["DB_NAME"]
_apply_db_env_override(main_db_config)
# 외부 API 키 env override — 키를 toml 에 안 두고 배포 환경변수로만 주입하는 경우.
def _apply_external_api_env_override(cfg: ExternalApiConfig):
if os.environ.get("PERPLEXITY_API_KEY"):
cfg.perplexity_api_key = os.environ["PERPLEXITY_API_KEY"]
if os.environ.get("KAKAO_REST_API_KEY"):
cfg.kakao_rest_api_key = os.environ["KAKAO_REST_API_KEY"]
if os.environ.get("NAVER_CLIENT_ID"):
cfg.naver_client_id = os.environ["NAVER_CLIENT_ID"]
if os.environ.get("NAVER_CLIENT_SECRET"):
cfg.naver_client_secret = os.environ["NAVER_CLIENT_SECRET"]
if os.environ.get("GEMINI_API_KEY"):
cfg.gemini_api_key = os.environ["GEMINI_API_KEY"]
if os.environ.get("GEMINI_VISION_MODEL"):
cfg.gemini_vision_model = os.environ["GEMINI_VISION_MODEL"]
if os.environ.get("GEMINI_TEXT_MODEL"):
cfg.gemini_text_model = os.environ["GEMINI_TEXT_MODEL"]
if os.environ.get("VISION_CONFIDENCE_THRESHOLD"):
cfg.vision_confidence_threshold = float(os.environ["VISION_CONFIDENCE_THRESHOLD"])
if os.environ.get("TOUR_API_KEY"):
cfg.tour_api_key = os.environ["TOUR_API_KEY"]
_apply_external_api_env_override(external_api_config)
# JWT 시크릿 env override.
# ★ 도커 이미지는 config.local.toml 을 example 사본(플레이스홀더)으로 굽는다 — 시크릿을 안 굽기 위해서다.
# 그래서 env 로 주입하지 않으면 "<JWT_ACCESS_SECRET>" 이라는 공개된 문자열이 서명 키가 된다.
# 운영에서는 반드시 env 로 넣어야 한다.
def _apply_jwt_env_override(cfg: JwtToken):
if os.environ.get("JWT_ACCESS_SECRET"):
cfg.access_key = os.environ["JWT_ACCESS_SECRET"]
if os.environ.get("JWT_REFRESH_SECRET"):
cfg.refresh_key = os.environ["JWT_REFRESH_SECRET"]
if os.environ.get("JWT_ACCESS_EXPIRE_MIN"):
cfg.access_expire_min = int(os.environ["JWT_ACCESS_EXPIRE_MIN"])
if os.environ.get("JWT_REFRESH_EXPIRE_DAY"):
cfg.refresh_expire_day = int(os.environ["JWT_REFRESH_EXPIRE_DAY"])
_apply_jwt_env_override(jwt_token_config)
# 플레이스홀더가 그대로 남아 있으면(=env 미주입 도커) 눈에 띄게 경고한다.
# 부팅은 막지 않는다 — 로컬 개발에서 토큰이 안 필요한 작업까지 못 하게 되면 곤란하다.
if jwt_token_config.access_key.startswith("<") or jwt_token_config.refresh_key.startswith("<"):
# 부팅은 막지 않는다 — 토큰이 필요 없는 로컬 작업까지 못 하게 되면 곤란하다.
if not jwt_token_config.access_key or not jwt_token_config.refresh_key:
from common.logger import LOG
LOG.w(
"[보안] JWT 시크릿이 플레이스홀더입니다 — JWT_ACCESS_SECRET / JWT_REFRESH_SECRET 를 env 로 주입하세요. "
"이 상태로 운영에 띄우면 서명 키가 공개값입니다."
)
LOG.w("[보안] JWT 시크릿이 비어 있습니다 — .env 에 JWT_ACCESS_SECRET / JWT_REFRESH_SECRET 를 넣으세요.")

View File

@ -1,4 +1,4 @@
# 테스트는 APP_ENV=test 로 실행한다 (config.test.toml → web4ai_test_db, dev DB 와 분리).
# 테스트는 APP_ENV=test 로 실행한다 (DB 이름 기본값이 web4ai_test_db 로 갈린다, dev DB 와 분리).
# 이 픽스처들은 TRUNCATE 를 하므로 dev DB(web4ai_db)와 절대 공유하면 안 된다(아래 db_engine 안전가드 참고).
# config.server_configs 가 import 되는 순간 config.<APP_ENV>.toml 을 읽으므로 가장 먼저 설정.
import os
@ -73,13 +73,13 @@ async def db_engine(_test_db_lifecycle):
"""테스트용 스키마를 보장하고, 매 테스트 시작 시 테이블을 비워 격리한다.
픽스처는 TRUNCATE 한다 dev DB(web4ai_db) 가리키면 실데이터가 날아간다.
그래서 test 전용 DB(이름에 'test') 아니면 즉시 중단한다(config.test.toml / APP_ENV=test).
그래서 test 전용 DB(이름에 'test') 아니면 즉시 중단한다(APP_ENV=test).
(DB_SESSION_MNG) APP_ENV=test 같은 test DB 접속하므로 여기서 만든 스키마를 공유한다.
"""
# 안전가드: dev DB 오염 방지. web4ai_test_db 이외엔 절대 실행하지 않는다.
assert "test" in main_db_config.name, (
f"테스트가 비-test DB('{main_db_config.name}')를 가리킵니다. "
"APP_ENV=test(config.test.toml)로 실행하세요. dev DB 보호를 위해 중단합니다."
"APP_ENV=test 로 실행하세요. dev DB 보호를 위해 중단합니다."
)
engine = create_async_engine(
_write_url(main_db_config),

View File

@ -10,5 +10,5 @@ pydantic>=2.0
python-multipart
httpx
apscheduler>=3.10
python-dotenv # 레포 최상위 .env 로드(외부 API 키·DB 접속 주입)
pydantic-settings # 환경변수·.env 로드 (FastAPI 공식 설정 방식)
azure-storage-blob>=12.19

View File

@ -9,9 +9,3 @@ VITE_PUBLISH_HOST=w4ai.o2o.kr
# 발행 사이트 렌더러 개발 서버. 에디터 상단 "발행본 사이트 열기" 가 이 주소를 연다.
VITE_SITE_PREVIEW_URL=http://localhost:3000
# ── 개발 전용 자동 로그인(선택) ────────────────────────────
# 값이 있으면 개발 서버에서 빌더를 열 때 이 계정으로 자동 로그인한다.
# 운영 번들에는 들어가지 않는다. 실제 값은 .env 에만 둔다(커밋 금지).
VITE_DEV_LOGIN_ID=
VITE_DEV_LOGIN_PW=

View File

@ -17,9 +17,7 @@ export const router = createBrowserRouter([
*
* .
*
* :
* useDevAutoLogin() .env
* , (2 )
* , (2 ) .
*
* (6) AppShell BuilderPage .
*/

View File

@ -12,7 +12,6 @@ import {
verifyPlaceByUrl,
} from '@/api';
import type {ConfirmedIdentity} from '@/stores/builder';
import {ensureDevSession} from '@/lib/devSession';
import {notify, notifyApiError} from '@/lib/notify';
/** 빌더 업종 → places.category. 반대 방향은 stores/builder 의 CATEGORY_TO_INDUSTRY 다. */
@ -128,10 +127,6 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
const controller = new AbortController();
inflight.current = controller;
// ★ 개발 서버라면 자동 로그인이 끝날 때까지 기다린다. 이걸 안 기다리면 페이지를 열자마자
// 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
await ensureDevSession();
if (!getAccessToken()) {
setState({
...INITIAL,

View File

@ -1,14 +0,0 @@
import {useEffect} from 'react';
import {ensureDevSession} from '@/lib/devSession';
/**
* .
*
* `lib/devSession` (usePlaceSearch)
* , .
*/
export function useDevAutoLogin() {
useEffect(() => {
void ensureDevSession();
}, []);
}

View File

@ -1,47 +0,0 @@
import {getAccessToken, login, me} from '@/api';
import {toAuthUser, useAuthStore} from '@/stores/auth';
import {UserRole} from '@/api';
/**
* .
*
* 싱글턴인가: 로그인은 , (2 )
* . "로그인 끝나기 전에 누른 검색"
* '로그인 만료' .
* ** **, .
*
* . import.meta.env.DEV false .env
* ( .env ).
*/
let pending: Promise<void> | null = null;
export function ensureDevSession(): Promise<void> {
if (!import.meta.env.DEV) return Promise.resolve();
if (getAccessToken()) return Promise.resolve();
if (pending) return pending;
const id = import.meta.env.VITE_DEV_LOGIN_ID;
const password = import.meta.env.VITE_DEV_LOGIN_PW;
if (!id || !password) return Promise.resolve();
pending = (async () => {
try {
const res = await login({id, password});
if (!res.access_token || !res.refresh_token) return;
const tokens = {accessToken: res.access_token, refreshToken: res.refresh_token};
// 순서가 중요하다. signIn 이 토큰을 저장하고, 그 토큰으로 me() 가 나간다 —
// 먼저 me() 를 부르면 토큰이 아직 없어 401 로 떨어진다(로그인 화면과 같은 순서다).
useAuthStore.getState().signIn(tokens, {userId: '', id, role: UserRole.USER});
const meRes = await me();
if (meRes.user_id && meRes.id) useAuthStore.getState().signIn(tokens, toAuthUser(meRes));
} catch {
// 개발 편의 기능이다 — 실패해도 화면을 막지 않는다(2단계가 안내를 띄운다).
} finally {
// 실패했으면 다음 시도에서 다시 붙을 수 있게 비운다.
if (!getAccessToken()) pending = null;
}
})();
return pending;
}

View File

@ -11,7 +11,6 @@ import {
Step5Generating,
} from '@/features/onboarding';
import {EditorLayout} from '@/features/builder';
import {useDevAutoLogin} from '@/hooks/useDevAutoLogin';
import {usePlaceSync} from '@/hooks/usePlaceSync';
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
@ -41,7 +40,6 @@ export function BuilderPage() {
* placeId .
*/
// 개발 서버에서는 세션을 조용히 확보한다 — 위저드 앞에 로그인 화면을 세우지 않기 위해서다.
useDevAutoLogin();
const [searchParams, setSearchParams] = useSearchParams();
const urlPlaceId = searchParams.get('placeId');

View File

@ -4,9 +4,6 @@ interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
readonly VITE_PUBLISH_HOST?: string;
readonly VITE_SITE_PREVIEW_URL?: string;
/** 개발 전용 자동 로그인 계정. 운영에서는 비어 있다 — useDevAutoLogin 참조. */
readonly VITE_DEV_LOGIN_ID?: string;
readonly VITE_DEV_LOGIN_PW?: string;
}
interface ImportMeta {