o2o-site-AEO/.env.example
hbyang 0e0f2cf038 [feat] solution,postgres-init,docs: SNS 게재 — 사장님이 누르면 쓰고, 승인받아, 사장님 계정으로 올린다
발행한 사이트로 사람을 데려올 경로가 제품 안에 없었다. IndexNow 통보와 사이트맵뿐이고 그건
검색엔진이 언제 읽을지 우리가 모른다. 이제 사장님이 [Threads에 알리기] 를 누르면 확인된 fact 로
짧은 글을 쓰고, 승인을 받아 사장님 개인 계정으로 올린다. 올린 글은 발행본 맨 아래에도 실린다.

★ 이 레포가 처음으로 ①외부에 쓰기를 하고 ②남의 계정 자격증명을 보관하고 ③되돌릴 수 없는
  행위를 한다. 아래 결정이 전부 여기서 나왔다.

승인을 다시 둔다 — 7절("승인 없이 나간다")의 예외다(DECISIONS 7-1). 기준은 문장의 참/거짓이
아니라 명의(사장님 계정의 발언) · 회수 가능성(없다) · 무엇이 주로 틀리나(문장이 아니라 링크 —
`_publish_target` 이 계산하므로 앞 게이트가 못 본다)다. 7절의 함정은 구조로 막았다:
시작이 사장님 클릭이라 "안 눌러서 영영 안 나감" 이 생기지 않고, 승인 경로가 둘(화면·알림톡)이며,
미승인은 EXPIRED 로 화면에 보이게 남는다.

★ 게시는 `domain` 이 확정된 사이트에만. 비면 슬러그가 상호명에서 파생돼(`_publish_target`)
  상호를 고치는 순간 주소가 바뀌고, 이미 올라간 글의 링크는 404 가 된다 — 그 글은 수정할 수 없다.
★ 승인은 GET 이 아니라 POST. 메신저 링크 미리보기·백신·프리페치가 사람이 누르기 전에 URL 을
  연다. 일회성은 토큰이 아니라 `status='PENDING_APPROVAL'` 조건이 붙은 단일 UPDATE 가 보장한다.
★ 사진은 올리지 않는다 — 1-2 의 격리("나중에 필터로 뺀다")가 SNS 에서는 구조적으로 불가능하다.
  필터가 아니라 첨부 코드를 아예 만들지 않았다.
★ 게시는 기본으로 꺼져 있다(`SOCIAL_POSTING_ENABLED=0`). 플랫폼 계약과 1-4(해지 시 처리)
  결론을 확인한 뒤 사람이 연다 — 1-4 가 이 기능의 전제조건이 됐다.

플랫폼은 스레드다. X 는 URL 이 든 글에 요청당 $0.20 이 안내돼 있어 "계정 단위 고정비" 라는
처음 가정이 틀렸다(사이트마다 나가는 변동비다). 어댑터 경계는 두되 X 어댑터는 넣지 않았다.

- place_social_posts · owner_social_accounts 신설(init.sql + 0012·0013). 승인 대기는 잡이 아니라
  행의 상태다 — 잡으로 매달면 lease 만료로 DEAD 가 된다
- services/social_service · social_account_service · notify_service · external/{threads,alimtalk,social}
- router/v1/social — GET 은 상태를 바꾸지 않고, POST 가 링크·계정을 재검사한 뒤 CAS 한다
- 빌더 SocialPanel(발행 완료 화면) + 무인증 승인 페이지 `/approve/:postId`
- 발행본 SocialPostsSection — 정적 카드 + 원문 링크. 위젯·임베드 없음. 고유 콘텐츠 계수에서 제외
- nginx: `/approve/` 는 no-referrer · no-store · noindex + 액세스 로그 끔

밟은 함정 둘
- ORM 기본값에 쉼표가 딸려 들어갔다: `text("'[]',")` → `DEFAULT '[]', NOT NULL` 로 나가
  CREATE TABLE 이 통째로 실패. 운영 DB 는 init.sql 로 만들어져 안 드러나고 ORM 이 스키마를
  만드는 테스트 DB 에서만 터진다 — 09-10 의 `now()` 기본값 사고와 같은 자리다
- 승인 스윕이 1분 주기라 쓰기 커넥션을 계속 집어 들었다 → 5분. 이 스윕은 만료 표시와 중단 정리뿐이라
  분 단위 정밀도가 필요 없다

검증: 백엔드 645 passed / 5 failed(전부 환경 — 프론트 소스 부재·레이트리밋).
★ 테스트에 실제 API 키가 새면 BUILD 잡이 Suno·Perplexity 를 진짜로 부른다(실측: 한 파일 12분 →
키를 비우면 10초). 키를 비운 상태가 정상 실행 조건이다.
에디터 목록 대조(test_site_theme) 22건 통과 · tsc·eslint 통과 · vitest 62 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 15:46:44 +09:00

139 lines
7.5 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
# ── SNS 게재(스레드) ────────────────────────────────────────────────
# 사장님이 [SNS에 알리기] 를 누르면 확인된 fact 로 짧은 글을 쓰고, 승인을 받아
# **사장님 개인 계정**으로 올린다. 비우면 그 기능만 꺼진다(서버는 뜬다).
#
# ★ SOCIAL_TOKEN_SECRET 이 없으면 계정 연결 자체를 막는다 — 위임받은 토큰을
# 평문으로 보관하는 길을 열지 않는다. 우리 API 키와 성격이 다르다:
# API 키는 우리 돈이 나가고, 이 토큰은 **사장님 이름으로 글이 나간다.**
SOCIAL_TOKEN_SECRET=
# ★ 실제 게시는 이 값이 '1' 일 때만 열린다. 플랫폼 계약과 해지 안내 페이지 정책
# (DECISIONS 1-4)을 확인하기 전에는 초안·승인까지만 돌린다 — 게시는 되돌릴 수 없다.
SOCIAL_POSTING_ENABLED=0
# 승인 요청의 수명. 지나면 EXPIRED 로 내려가고 화면에 '만료됨 · 다시 보내기' 로 남는다.
SOCIAL_APPROVAL_HOURS=24
# 승인 화면이 열리는 주소(빌더 SPA). 알림톡 버튼이 이 주소로 간다.
SOCIAL_APP_ORIGIN=
THREADS_APP_ID=
THREADS_APP_SECRET=
THREADS_REDIRECT_URI=
# 알림톡(대행사). 비면 발송을 건너뛰고 빌더 화면 승인만 쓴다 — 기능은 그대로 돈다.
# ★ 템플릿 코드는 심사 대상이라 env 로 둔다. 반려로 코드가 바뀌면 배포 없이 고쳐야 한다.
ALIMTALK_API_KEY=
ALIMTALK_API_SECRET=
ALIMTALK_PROFILE_ID=
ALIMTALK_SENDER=
ALIMTALK_TEMPLATE_CODE=
# 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다).
# 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=
# SNS — Threads 우선(2026-09-14). SOCIAL_TOKEN_SECRET은 Fernet.generate_key() 형식의 키.
# 키·앱 설정 없으면 연결 비활성, 초안/복사/화면 확인은 동작한다.
SOCIAL_TOKEN_SECRET=
THREADS_APP_ID=
THREADS_APP_SECRET=
THREADS_REDIRECT_URI=https://web4ai.o2osolution.ai/v1/social/oauth/callback
SOCIAL_APP_ORIGIN=https://web4ai.o2osolution.ai
SOCIAL_APPROVAL_HOURS=24
# 앱 심사·테스트 계정 게시·해지 안내 페이지 정책 검증 후 활성화.
SOCIAL_POSTING_ENABLED=0
# 대행사 선택 전 비워 둔다. 현재 어댑터는 SOLAPI 계약이며 교체는 external/alimtalk.py만.
ALIMTALK_API_KEY=
ALIMTALK_API_SECRET=
ALIMTALK_PROFILE_ID=
ALIMTALK_SENDER=
ALIMTALK_TEMPLATE_CODE=