From 9b4fe4030bb8628a0b6f047f15a9253fd008eee4 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Tue, 1 Sep 2026 10:04:36 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20deploy,backend,site:=20=ED=82=B9?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EB=B0=B0=ED=8F=AC=20+=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20=EC=B5=9C=EC=83=81=EC=9C=84=20.env=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 킹서버(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·실키 미주입 확인. --- .dockerignore | 2 - .env.example | 102 ++++------ AGENTS.md | 147 +++++++++++++- README.md | 6 +- admin/backend/app.py | 2 +- admin/backend/main.py | 2 +- deploy.sh | 102 ++++++++++ docker-compose.yml | 66 +++--- docs/ARCHITECTURE.md | 22 +- docs/DECISIONS.md | 4 +- docs/DEPLOY.md | 23 ++- docs/DEVLOG.md | 51 +++++ docs/SERVERS.md | 119 +++++++++++ log.sh | 87 ++++++++ solution/backend/Dockerfile | 2 - solution/backend/README.md | 16 +- .../backend/config/config.local.toml.example | 52 ----- .../backend/config/config.test.toml.example | 51 ----- solution/backend/config/config_loader.py | 37 ---- solution/backend/config/config_models.py | 192 +++++++++++++----- solution/backend/config/server_configs.py | 123 ++--------- solution/backend/conftest.py | 6 +- solution/backend/requirements.txt | 2 +- solution/frontend/.env.example | 6 - solution/frontend/src/app/router.tsx | 4 +- .../src/features/onboarding/usePlaceSearch.ts | 5 - .../frontend/src/hooks/useDevAutoLogin.ts | 14 -- solution/frontend/src/lib/devSession.ts | 47 ----- solution/frontend/src/pages/BuilderPage.tsx | 2 - solution/frontend/src/vite-env.d.ts | 3 - 30 files changed, 774 insertions(+), 523 deletions(-) create mode 100755 deploy.sh create mode 100644 docs/DEVLOG.md create mode 100644 docs/SERVERS.md create mode 100755 log.sh delete mode 100644 solution/backend/config/config.local.toml.example delete mode 100644 solution/backend/config/config.test.toml.example delete mode 100644 solution/backend/config/config_loader.py delete mode 100644 solution/frontend/src/hooks/useDevAutoLogin.ts delete mode 100644 solution/frontend/src/lib/devSession.ts diff --git a/.dockerignore b/.dockerignore index ad2c509..c496829 100644 --- a/.dockerignore +++ b/.dockerignore @@ -24,8 +24,6 @@ solution/backend/tests/ solution/backend/loadtest/ # 시크릿 — 이미지에 굽지 않는다. Dockerfile 이 example 을 복사해 넣고 실값은 compose env 로 준다. -**/config.local.toml -**/config.test.toml .env .env.* !.env.example diff --git a/.env.example b/.env.example index 8a0878b..07efa03 100644 --- a/.env.example +++ b/.env.example @@ -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/`). -# 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) diff --git a/AGENTS.md b/AGENTS.md index b848d2d..de0e22b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/` 만 올린다 — **렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.** - → `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/` (빌더 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/.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` 이 이미 안다 diff --git a/README.md b/README.md index 0539654..db1ef28 100644 --- a/README.md +++ b/README.md @@ -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/`` · 운영은 :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`). diff --git a/admin/backend/app.py b/admin/backend/app.py index 93089ed..f95839c 100644 --- a/admin/backend/app.py +++ b/admin/backend/app.py @@ -1,4 +1,4 @@ -"""내부 운영 API. 사장님 API(:9800)와 프로세스·포트가 갈린다. +"""어드민 API. 솔루션 API(:9800)와 프로세스·포트가 갈린다. 도메인 코드는 solution/backend 것을 PYTHONPATH 로 쓴다 — admin 전용 라우터가 0개라 (전부 place·fact) 새로 쓰면 같은 테이블을 두 벌 구현하는 것뿐이다. diff --git a/admin/backend/main.py b/admin/backend/main.py index d8fb0bf..62c6810 100644 --- a/admin/backend/main.py +++ b/admin/backend/main.py @@ -1,4 +1,4 @@ -# 내부 운영 API 서버 (:9801). 근거는 app.py 주석. +# 어드민 API 서버 (:9801). 근거는 app.py 주석. # PYTHONPATH=../../solution/backend python main.py import os diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..fb15edf --- /dev/null +++ b/deploy.sh @@ -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" diff --git a/docker-compose.yml b/docker-compose.yml index 80195bd..9df648e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 \ # 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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8cbfc2d..9e5a7ab 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -35,7 +35,7 @@ BUILD 잡 (worker) ─ services/build_service.py:99 run_build() ├ 1차 게이트 (DB 사실 기준) → publish_gate.evaluate() ├ site_payload.emit_payload() → out/payloads/.json ★ 백엔드의 유일한 산출물 │ - │ ┌ [별도 컨테이너 o2o-web4ai-web] + │ ┌ [별도 컨테이너 o2o-web4ai-solution-frontend] │ │ scripts/watch-payloads.mjs — payloads/ 2초 폴링, 바뀐 것만 │ │ └ node dist/prerender/prerender.js --payload= │ │ → out/s//** + 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` 로 붙는다. 데이터 수명이 컨테이너 수명과 달라야 해서다. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 55675e7..9e57729 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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` 어댑터만 비활성이고 주변 정보 블록이 비어 뜬다 | ### 아직 테이블이 없는 것 diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index d4f15eb..dda1c85 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -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/` 200 · `out/s//index.html` 생성 · `out/payloads/.status/.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/` (끝 슬래시 없음)이 열리는지도 이 스크립트가 본다 — 사장님이 주소창에 치는 형태가 그거다. @@ -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://.z*.web.core.windows.net/s/` 가 **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` 이 **같은 값**이어야 한다 (프리렌더가 루트에 `.txt` 를 굽고 검색엔진이 대조한다 — 어긋나면 403) 5. `https://<도메인>/.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/`** 뿐이다. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md new file mode 100644 index 0000000..b0ec3bf --- /dev/null +++ b/docs/DEVLOG.md @@ -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 가 없어 인프라 몫이다. diff --git a/docs/SERVERS.md b/docs/SERVERS.md new file mode 100644 index 0000000..960e495 --- /dev/null +++ b/docs/SERVERS.md @@ -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 이 뜬다. 즉 앞단에 포워딩/리버스프록시가 있고, 우리 주소도 거기에 태워야 한다. +그 앞단은 이 레포 밖이라 인프라 담당에게 요청해야 한다. diff --git a/log.sh b/log.sh new file mode 100755 index 0000000..60b2123 --- /dev/null +++ b/log.sh @@ -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[@]}"} diff --git a/solution/backend/Dockerfile b/solution/backend/Dockerfile index c08dbf3..b6fef95 100644 --- a/solution/backend/Dockerfile +++ b/solution/backend/Dockerfile @@ -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 하는 경로. diff --git a/solution/backend/README.md b/solution/backend/README.md index 1edc26e..d096525 100644 --- a/solution/backend/README.md +++ b/solution/backend/README.md @@ -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 보호). diff --git a/solution/backend/config/config.local.toml.example b/solution/backend/config/config.local.toml.example deleted file mode 100644 index 22040ae..0000000 --- a/solution/backend/config/config.local.toml.example +++ /dev/null @@ -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 = "" -write_pw = "" -read_host = "127.0.0.1" -read_port = 5432 -read_id = "" -read_pw = "" -show_log = false -pool_size = 10 -max_overflow = 20 -sslmode = "" # 로컬: "" / 관리형 DB: "require"|"verify-ca"|"verify-full" - -[JwtToken] -access_key = "" -refresh_key = "" -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 = "" # 공공데이터포털 전국문화축제표준데이터 일반 인증키 diff --git a/solution/backend/config/config.test.toml.example b/solution/backend/config/config.test.toml.example deleted file mode 100644 index 7c3d7d5..0000000 --- a/solution/backend/config/config.test.toml.example +++ /dev/null @@ -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..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 = "" diff --git a/solution/backend/config/config_loader.py b/solution/backend/config/config_loader.py deleted file mode 100644 index 8e1f7a0..0000000 --- a/solution/backend/config/config_loader.py +++ /dev/null @@ -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) diff --git a/solution/backend/config/config_models.py b/solution/backend/config/config_models.py index bf5bf37..c6cdf6e 100644 --- a/solution/backend/config/config_models.py +++ b/solution/backend/config/config_models.py @@ -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() diff --git a/solution/backend/config/server_configs.py b/solution/backend/config/server_configs.py index cc4a821..e1b4510 100644 --- a/solution/backend/config/server_configs.py +++ b/solution/backend/config/server_configs.py @@ -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 로 주입하지 않으면 "" 이라는 공개된 문자열이 서명 키가 된다. -# 운영에서는 반드시 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 를 넣으세요.") diff --git a/solution/backend/conftest.py b/solution/backend/conftest.py index d980cfa..1c5767b 100644 --- a/solution/backend/conftest.py +++ b/solution/backend/conftest.py @@ -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..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), diff --git a/solution/backend/requirements.txt b/solution/backend/requirements.txt index bd1cd65..d46efb0 100644 --- a/solution/backend/requirements.txt +++ b/solution/backend/requirements.txt @@ -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 diff --git a/solution/frontend/.env.example b/solution/frontend/.env.example index 98c392a..a65c193 100644 --- a/solution/frontend/.env.example +++ b/solution/frontend/.env.example @@ -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= diff --git a/solution/frontend/src/app/router.tsx b/solution/frontend/src/app/router.tsx index 1f6d35d..cbba7f3 100644 --- a/solution/frontend/src/app/router.tsx +++ b/solution/frontend/src/app/router.tsx @@ -17,9 +17,7 @@ export const router = createBrowserRouter([ * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면 * 만들어 보기도 전에 막힌다. * - * 대신 세션은 조용히 확보한다: - * 개발 → useDevAutoLogin() 이 .env 의 개발 계정으로 자동 로그인한다 - * 운영 → 이미 로그인돼 있으면 그대로 쓰고, 아니면 서버가 필요한 순간(2단계 검색)에만 알린다 + * 대신 이미 로그인돼 있으면 그대로 쓰고, 아니면 서버가 필요한 순간(2단계 검색)에만 알린다. * * 에디터(6단계)는 전체 화면이 필요해 AppShell 을 스스로 끄고 켠다 — BuilderPage 참조. */ diff --git a/solution/frontend/src/features/onboarding/usePlaceSearch.ts b/solution/frontend/src/features/onboarding/usePlaceSearch.ts index 184de3c..37549e2 100644 --- a/solution/frontend/src/features/onboarding/usePlaceSearch.ts +++ b/solution/frontend/src/features/onboarding/usePlaceSearch.ts @@ -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, diff --git a/solution/frontend/src/hooks/useDevAutoLogin.ts b/solution/frontend/src/hooks/useDevAutoLogin.ts deleted file mode 100644 index eed79a8..0000000 --- a/solution/frontend/src/hooks/useDevAutoLogin.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {useEffect} from 'react'; -import {ensureDevSession} from '@/lib/devSession'; - -/** - * 화면이 열리자마자 개발 세션을 확보한다. - * - * 실제 로직은 `lib/devSession` 에 있다 — 서버를 부르는 쪽(usePlaceSearch)도 같은 약속을 - * 기다려야 하기 때문에, 훅 바깥에 두고 공유한다. - */ -export function useDevAutoLogin() { - useEffect(() => { - void ensureDevSession(); - }, []); -} diff --git a/solution/frontend/src/lib/devSession.ts b/solution/frontend/src/lib/devSession.ts deleted file mode 100644 index ca794f1..0000000 --- a/solution/frontend/src/lib/devSession.ts +++ /dev/null @@ -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 | null = null; - -export function ensureDevSession(): Promise { - 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; -} diff --git a/solution/frontend/src/pages/BuilderPage.tsx b/solution/frontend/src/pages/BuilderPage.tsx index 93de900..81821f8 100644 --- a/solution/frontend/src/pages/BuilderPage.tsx +++ b/solution/frontend/src/pages/BuilderPage.tsx @@ -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'); diff --git a/solution/frontend/src/vite-env.d.ts b/solution/frontend/src/vite-env.d.ts index 95d9b4c..f9f092a 100644 --- a/solution/frontend/src/vite-env.d.ts +++ b/solution/frontend/src/vite-env.d.ts @@ -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 {