/s/stay 시안의 헤더에는 노래 플레이어가 있는데 그건 손으로 채운 목업이라, 새로 발행한 사이트에는 그 자리가 아예 없었다. 이제 발행이 노래를 만든다. ★ 발행이 노래를 기다린다. BUILD 잡이 스냅샷을 뜨기 **전에** 곡을 만든다 — 먼저 굽고 나중에 붙이면 사장님이 [사이트 열기] 로 보는 첫 화면에 그 기능이 빠져 있다. 값은 발행이 30~40초(실측, 상한 5분) 늦어지는 것이고 그건 감수한다. 단 실패는 발행을 막지 않는다 — 기다리는 것과 막는 것은 다르다. 키가 없거나 작곡이 실패하면 노래 없이 발행되고 사유가 빌드 로그와 place_songs.last_error 에 남는다. ★ 가사를 우리가 쓴다. Suno 에 주제만 던지면 가사를 저쪽이 짓고, 거기엔 이 숙소에 없는 것(수영장·조식)이 섞이는데 검증할 방법이 없다 — 다른 모든 문장은 확인된 fact 로만 쓰면서 노래만 지어낸 말을 싣는 꼴이다. 소개문과 **같은 재료**로 Gemini 가 쓰고 Suno 는 곡만 붙인다. 가사에 ground_check 는 걸지 않는다(정서는 fact 로 대응되지 않는다). 대신 프롬프트가 없는 시설·숫자를 말하지 말라고 못 박는다 — 요금을 노래에 넣으면 틀렸을 때 고쳐 부를 수 없다. ★ Suno 주소는 만료된다. 그 주소를 payload 에 실으면 발행 직후엔 재생되고 몇 주 뒤 조용히 죽는다. mp3 를 받아 보관하고 우리 경로(/s/<slug>/<song_id>.mp3)만 내보낸다. ★ 콜백이 아니라 폴링이다. 우리 백엔드는 Suno 가 닿을 수 있는 주소가 아니라, 콜백을 믿으면 "요청은 성공했는데 결과가 영영 안 옴" 이 된다. - services/external/suno.py: 작곡 요청 + record-info 폴링(10초 간격·상한 5분) + 내려받기 - services/external/gemini_text.generate_song · prompts/song.py: 가사·제목·장르 - services/song_service.py: 재료 → 가사 → 작곡 → 파일 보관. ensure_song 을 빌드가 부른다 - build_service: publish 일 때만 ensure_song 을 먼저 부르고 그 뒤 스냅샷(미리보기는 안 만든다 — 유료) - place_songs 표 신설(init.sql + 0010 마이그레이션 + ORM). 검증 상태가 없다 — 수집한 사실이 아니라 창작물이라 "맞는가" 가 아니라 "만들어졌는가" 만 묻는다(SongStatus) - snapshot·site_payload·shared: READY 인 최신 한 곡만 싣는다. audioUrl 은 우리 경로다 - prerender: songs/ 의 파일을 사이트 디렉토리로 복사하고 **지난 발행의 곡은 치운다** (발행마다 새 곡이라 안 치우면 1MB 짜리가 쌓이고 블롭에도 그대로 올라간다) - site/SongPlayer: 헤더의 작은 플레이어. 자동 재생하지 않고, 곡이 없으면 아무것도 안 그린다. 패널은 hidden 으로 여닫는다 — 조건부 렌더면 닫힌 동안 제목·가사가 DOM 에 없어 크롤러가 못 읽는다(오디오 안의 말은 어차피 못 듣는다) - azure_static: .mp3 content-type 과 immutable 캐시. 블롭 업로드는 발행이 사이트째 한다 — 업로더를 하나 더 두면 같은 컨테이너에 경로·캐시·정리 규칙이 두 벌 생긴다 - compose: solution/site/songs 볼륨. .env.example 에 SUNO_API_KEY·SUNO_CALLBACK_URL 검증: 실제 발행(스테이,머뭄 v15) — 가사 154자 $0.0014 → 작곡 40초 → 1.98MB → 스냅샷(노래 1) → 발행 완료. /s/스테이머뭄-99a887f8 200, mp3 200 audio/mpeg, HTML 에 제목·가사·주소 확인, 지난 곡 404. tsc --noEmit · eslint · vitest 55 passed(신규 4) · 백엔드 관련 188 passed (실패 5건은 전부 컨테이너 환경 유입 — 프론트 소스 부재·SITE_PUBLIC_HOST) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
96 lines
5.2 KiB
Plaintext
96 lines
5.2 KiB
Plaintext
# cp .env.example .env 후 값을 채운다. .env 는 커밋되지 않는다.
|
|
# 우선순위: 실제 환경변수(compose) > .env > 코드 기본값(config_models.py)
|
|
#
|
|
# ★ 값 뒤에 주석을 붙이지 않는다. compose 의 `env_file` 은 줄 끝 주석을 **값으로 읽는다** —
|
|
# `AZURE_STORAGE_CONNECTION_STRING= # 비우면...` 은 "빈 값"이 아니라 "# 비우면..." 이라는 값이다.
|
|
# 실측(2026-09-07): 그래서 Azure 를 끈 로컬에서 발행 잡이 업로드를 시도하고
|
|
# "Connection string is either blank or malformed" 로 죽었다. 게이트는 통과했는데 발행만 실패한다.
|
|
# 주석은 반드시 **윗줄**에 둔다.
|
|
|
|
# ── 공통 solution/backend · admin/backend (server_configs 가 읽는다)
|
|
APP_ENV=local
|
|
|
|
# ★ compose 로 띄우면 `host.docker.internal` 이다 — 컨테이너 안의 127.0.0.1 은 그 컨테이너다.
|
|
# 127.0.0.1 은 백엔드를 **네이티브로**(.venv/bin/python) 돌릴 때만 맞다.
|
|
# 이 값을 그대로 두고 `docker compose up` 하면 API 는 healthz 200 으로 멀쩡해 보이는데
|
|
# 워커만 조용히 재시작을 반복한다(ConnectionRefusedError 5432) — 발행 잡이 영원히 안 돈다.
|
|
DB_HOST=host.docker.internal
|
|
DB_PORT=5432
|
|
DB_USER=postgres
|
|
DB_PASSWORD=
|
|
DB_NAME=web4ai_db
|
|
|
|
# 비우면 토큰 서명이 안 된다
|
|
# 생성: python -c "import secrets; print(secrets.token_urlsafe(48))"
|
|
JWT_ACCESS_SECRET=
|
|
JWT_REFRESH_SECRET=
|
|
|
|
# 키가 비면 그 어댑터만 꺼진다. 서버는 뜬다.
|
|
PERPLEXITY_API_KEY=
|
|
# Perplexity 채널 발견. 0=끔(기본)
|
|
COLLECT_USE_PERPLEXITY=0
|
|
NAVER_CLIENT_ID=
|
|
NAVER_CLIENT_SECRET=
|
|
# 미발급. 없으면 네이버 지역검색을 쓴다
|
|
KAKAO_REST_API_KEY=
|
|
GEMINI_API_KEY=
|
|
# 디코딩된 키(인코딩 키는 이중 인코딩된다)
|
|
TOUR_API_KEY=
|
|
# 발행할 때 이 숙소의 노래를 한 곡 만든다(가사 Gemini → 작곡 Suno).
|
|
# 비우면 그 단계만 건너뛴다 — 발행은 그대로 된다.
|
|
# ★ 콜백은 쓰지 않고 폴링한다(우리 서버는 Suno 가 닿을 수 있는 주소가 아니다).
|
|
# 그래도 API 가 필수로 요구하는 필드라 값을 채워 보낸다.
|
|
SUNO_API_KEY=
|
|
SUNO_CALLBACK_URL=https://example.com/api/suno/callback
|
|
|
|
|
|
# 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다).
|
|
# Google Cloud Console > API 및 서비스 > 사용자 인증 정보 > OAuth 2.0 클라이언트 ID(웹 애플리케이션)
|
|
# "승인된 JavaScript 원본" 에 화면 주소를 등록해야 브라우저에서 토큰이 나온다(리디렉션 URI 는 필요 없다).
|
|
# ★ 백엔드(aud 대조)와 프론트(버튼)가 **같은 값**을 써야 한다 — compose 가 이 하나를
|
|
# VITE_GOOGLE_CLIENT_ID 로 흘려보낸다. 두 곳에 따로 적지 않는다.
|
|
# ★ 바꾸면 프론트를 다시 구워야 한다: ./deploy.sh solution-site
|
|
GOOGLE_CLIENT_ID=
|
|
|
|
# CORS 허용 오리진. 쉼표로 여럿.
|
|
# 서버에 올리면 반드시 적는다. 안 적으면 화면은 뜨고 API 만 막힌다.
|
|
# CLIENT_URL=http://172.30.1.36:30031,http://localhost:3002
|
|
# LANDING_URL=
|
|
|
|
# ── solution/frontend 브라우저가 부르는 주소 (compose 가 VITE_* 로 주입)
|
|
# ★ 브라우저가 부르는 주소다. 서버에 올리면 localhost 는 즉시 틀린다.
|
|
# ★ **앱과 같은 오리진을 적는다.** nginx(:80)가 /v1 을 같은 오리진으로 프록시하므로
|
|
# (nginx/site.conf) 앱이 부를 주소는 `:9800` 이 아니라 앱 주소 그 자체다. `:9800` 을 적으면
|
|
# 스스로 크로스 오리진을 만들어 CORS 가 붙고, 화면은 뜨는데 **로그인만 계속 실패한다** —
|
|
# 서버는 200 에 토큰까지 내려보내고 브라우저가 allow-origin 이 없어 그 응답을 버린다.
|
|
# 실측(2026-09-07): 이 기본값 그대로 띄우면 :80 으로 연 앱에서 로그인이 안 된다.
|
|
# ★ 값을 바꾸면 번들을 다시 구워야 한다: ./deploy.sh solution-site
|
|
PUBLIC_API_BASE_URL=http://localhost
|
|
PUBLIC_WEB_BASE_URL=http://localhost
|
|
|
|
# ── solution/site 발행물 — solution/backend 도 같이 본다
|
|
# canonical·og:url·sitemap·IndexNow 가 전부 SITE_PUBLIC_HOST 를 쓴다.
|
|
# 로컬은 비워 둔다(기본값 localhost). 서버에 올릴 때만 실제 도메인을 적는다.
|
|
# SITE_PUBLIC_HOST=web4ai.o2osolution.ai
|
|
# 비우면 색인 통보를 건너뛴다(발행은 정상)
|
|
INDEXNOW_KEY=
|
|
# 비우면 로컬 발행만 한다
|
|
AZURE_STORAGE_CONNECTION_STRING=
|
|
AZURE_STORAGE_CONTAINER=
|
|
AZURE_STORAGE_PREFIX=
|
|
|
|
# ── 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)
|
|
|
|
# 자동 로그인 — 위저드 앞에 로그인 화면을 세우지 않으려고 세션을 미리 잡는다.
|
|
# ⚠️ 이 값은 **프론트 번들에 구워진다.** 페이지를 연 사람은 누구나 JS 에서 읽는다 —
|
|
# 내부 테스트 호스트에서만 채우고, 사장님에게 여는 순간 비운다(lib/autoSession.ts).
|
|
# ★ 바꾸면 재빌드해야 한다: ./deploy.sh solution-site
|
|
AUTO_LOGIN_ID=
|
|
AUTO_LOGIN_PW=
|