발행한 사이트로 사람을 데려올 경로가 제품 안에 없었다. 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>
201 lines
10 KiB
Plaintext
201 lines
10 KiB
Plaintext
# 공개 진입점 하나. 사장님 앱 · 발행 사이트 · API 가 **같은 오리진**을 쓴다.
|
|
#
|
|
# / → 사장님 앱 (이미지에 구워 넣은 정적 번들, /srv/app)
|
|
# /s/<slug> → 발행 사이트 (site-out 볼륨에서 정적)
|
|
# /assets/ → 발행본 공용 번들 (정적)
|
|
# /robots.txt · /sitemap.xml → 크롤러가 읽는 파일 (정적)
|
|
# /v1/... /healthz → API(solution-backend:9800)
|
|
#
|
|
# ★ 오리진을 가르지 않는 이유: robots.txt·sitemap.xml 은 RFC 9309 상 **오리진 루트에서만**
|
|
# 읽힌다. 앱과 사이트를 다른 호스트에 두면 인증서도 DNS 도 두 벌이 되고 CORS 가 붙는다.
|
|
# 개발에서는 Vite 프록시가 같은 일을 한다(solution/frontend/vite.config.ts).
|
|
#
|
|
# ★ 산출물은 named volume(site-out)으로 들어온다. 프리렌더가 쓰고 여기서 읽기만 한다 —
|
|
# 호스트 경로가 등장하지 않으므로 재배포로 코드를 갈아엎어도 사이트가 죽지 않는다.
|
|
|
|
server {
|
|
listen 80;
|
|
listen [::]:80;
|
|
server_name _;
|
|
|
|
# ★ nginx 이미지의 기본 문서 루트(/usr/share/nginx/html)를 쓰지 않는다.
|
|
# named volume 을 거기 마운트하면 Docker 가 **이미지에 들어 있던 index.html 을
|
|
# 빈 볼륨으로 복사한다** — 그러면 오리진 루트가 "Welcome to nginx!" 를 띄우고,
|
|
# 그게 크롤러에 잡힌다. 빈 경로에 마운트하면 복사될 것이 없다.
|
|
root /srv/sites;
|
|
|
|
charset utf-8;
|
|
server_tokens off;
|
|
client_max_body_size 20m;
|
|
|
|
# ★ Docker 내장 DNS. 업스트림을 변수로 두면 nginx 가 **기동할 때** 이름을 풀지 않는다 —
|
|
# 안 그러면 solution-backend 가 아직 안 떴을 때 nginx 자체가 죽는다.
|
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
|
set $api http://solution-backend:9800;
|
|
|
|
# 텍스트 산출물은 압축이 크게 먹는다(HTML 55KB → 10KB 안팎).
|
|
gzip on;
|
|
gzip_comp_level 6;
|
|
gzip_min_length 1024;
|
|
gzip_vary on;
|
|
gzip_types
|
|
text/plain text/css text/xml
|
|
application/javascript application/json application/xml
|
|
image/svg+xml;
|
|
|
|
# 승인 nonce·OAuth code가 액세스 로그나 Referer로 새지 않도록 별도로 서빙한다.
|
|
location ^~ /approve/ {
|
|
root /srv/app;
|
|
try_files $uri /index.html;
|
|
access_log off;
|
|
add_header Referrer-Policy "no-referrer" always;
|
|
add_header Cache-Control "no-store" always;
|
|
add_header X-Robots-Tag "noindex, nofollow" always;
|
|
}
|
|
|
|
# ── 발행 사이트 ────────────────────────────────────────────
|
|
# ★ 리다이렉트는 상대 Location 으로 낸다. 기본값(absolute_redirect on)은 `$scheme` 로
|
|
# 절대 URL 을 만드는데, TLS 는 앞단 Apache 가 끊으므로 여기 `$scheme` 는 늘 `http` 다 —
|
|
# `/s/` 를 접으면 https 페이지가 http 로 내려가는 리다이렉트가 나간다.
|
|
absolute_redirect off;
|
|
|
|
# ★ 발행본 목록의 정본 주소는 **`/s`** 다 — 슬러그 페이지(`/s/<slug>`)와 형태를 맞춘다.
|
|
# 이 블록이 없으면 `/s` 는 `^~ /s/` 에 안 걸려 맨 아래 `location /` 로 떨어지고
|
|
# **빌더 SPA 셸이 200 으로 나간다.** 404 도 목록도 아닌 세 번째 페이지가 크롤러에
|
|
# 잡힌다(실측 2026-09-08: `/s` 3.1KB 앱 셸 · `/s/` 6.7KB 목록).
|
|
location = /s {
|
|
root /srv/sites;
|
|
try_files /s/index.html =404;
|
|
add_header Cache-Control "public, max-age=300, must-revalidate";
|
|
}
|
|
|
|
# 옛 주소. 사이트맵·서치콘솔에 `/s/` 로 제출된 것이 남아 있다.
|
|
location = /s/ {
|
|
return 301 /s;
|
|
}
|
|
|
|
# ^~ 로 잡아 아래 정규식 location 들이 끼어들지 못하게 한다.
|
|
location ^~ /s/ {
|
|
# ★ `/s/<slug>/`(끝 슬래시) 를 위해 필요하다. try_files 의 첫 인자 `$uri` 가 끝
|
|
# 슬래시면 nginx 는 **디렉토리 검사**로 읽고, 디렉토리가 있으면 거기서 멈춘다 —
|
|
# index 지시자가 없으면 그 순간 403 이다(=404 로도 안 떨어진다).
|
|
index index.html;
|
|
# $uri/ 를 거치면 nginx 가 끝 슬래시로 301 을 내보낸다. 크롤러가 리다이렉트를
|
|
# 한 번 더 타야 하므로 index.html 을 바로 준다.
|
|
try_files $uri $uri/index.html =404;
|
|
add_header Cache-Control "public, max-age=300, must-revalidate";
|
|
}
|
|
|
|
# 파일명에 해시가 박혀 있다. 내용이 바뀌면 이름이 바뀌므로 영구 캐시가 안전하다.
|
|
# 빌더 미리보기 셸. **발행본 번들**을 띄우는 CSR 한 장이다
|
|
# (`solution/site/scripts/prerender.writePreviewShell`).
|
|
# ★ 빌더 SPA(`location /`)로 떨어지면 안 된다 — 거기로 가면 미리보기 안에 빌더가 또 뜬다.
|
|
# ★ iframe 으로 여는 이유는 뷰포트다. 빌더 안에 직접 그리면 미디어 쿼리가 창 폭을 봐서
|
|
# 그리드 컬럼 수가 발행본과 달라진다(실측 89% 픽셀 차이 — SitePreview 머리주석).
|
|
location = /preview {
|
|
root /srv/sites;
|
|
try_files /preview/index.html =404;
|
|
add_header Cache-Control "no-store" always;
|
|
}
|
|
|
|
location ^~ /assets/ {
|
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
|
access_log off;
|
|
try_files $uri =404;
|
|
}
|
|
|
|
# ★ 빌더와 발행본이 `/fonts/` 를 **각자** 쓴다(vite.config.ts 주석). 발행본을 먼저 보고
|
|
# 없으면 빌더 것으로 떨어뜨린다 — 한쪽만 잡으면 다른 쪽 폰트가 조용히 404 다.
|
|
location ^~ /fonts/ {
|
|
root /srv/sites;
|
|
add_header Cache-Control "public, max-age=604800";
|
|
access_log off;
|
|
try_files $uri @app_fonts;
|
|
}
|
|
location @app_fonts {
|
|
root /srv/app;
|
|
access_log off;
|
|
try_files $uri =404;
|
|
}
|
|
|
|
# ── 크롤러가 읽는 파일 ─────────────────────────────────────
|
|
# 발행하면 곧바로 반영되어야 한다. 길게 캐시하면 새 사업장이 사이트맵에 들어가도
|
|
# 크롤러가 옛 파일을 계속 본다.
|
|
location = /robots.txt {
|
|
add_header Cache-Control "public, max-age=300, must-revalidate";
|
|
try_files $uri =404;
|
|
}
|
|
|
|
location = /sitemap.xml {
|
|
add_header Cache-Control "public, max-age=300, must-revalidate";
|
|
try_files $uri =404;
|
|
}
|
|
|
|
# ── API ────────────────────────────────────────────────────
|
|
# 앱과 같은 오리진이라 프리플라이트가 아예 발생하지 않는다.
|
|
location ^~ /v1/social/ {
|
|
access_log off;
|
|
add_header Cache-Control "no-store" always;
|
|
add_header Referrer-Policy "no-referrer" always;
|
|
proxy_pass $api;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
|
# 발행·수집 잡은 분 단위다. 기본 60s 면 게이트웨이가 먼저 끊는다.
|
|
proxy_read_timeout 300s;
|
|
proxy_send_timeout 300s;
|
|
}
|
|
|
|
location ~ ^/(v1/|healthz$|openapi\.json$|docs|redoc) {
|
|
proxy_pass $api;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
|
# 발행·수집 잡은 분 단위다. 기본 60s 면 게이트웨이가 먼저 끊는다.
|
|
proxy_read_timeout 300s;
|
|
proxy_send_timeout 300s;
|
|
}
|
|
|
|
# IndexNow 키 파일. 프리렌더가 루트에 <key>.txt 를 굽고 검색엔진이 대조한다.
|
|
# ★ `{8,128}` 같은 수량자는 못 쓴다 — nginx 는 `{`·`}` 를 블록 구분자로 먼저 읽는다.
|
|
location ~ ^/[A-Za-z0-9_-]+\.txt$ {
|
|
try_files $uri =404;
|
|
}
|
|
|
|
# ── 사장님 앱 (그 외 전부) ─────────────────────────────────
|
|
# 해시가 박힌 번들. 내용이 바뀌면 이름이 바뀌므로 영구 캐시가 안전하다.
|
|
# ★ 발행본 `/assets/` 와 겹치지 않게 빌더만 `builder-assets` 로 뺐다
|
|
# (solution/frontend/vite.config.ts 의 build.assetsDir).
|
|
location ^~ /builder-assets/ {
|
|
root /srv/app;
|
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
|
access_log off;
|
|
try_files $uri =404;
|
|
}
|
|
|
|
# ★ 폴백은 `/index.html` 이 아니라 `__spa-fallback.html` 이다.
|
|
# 프리렌더를 켠 뒤로 `/index.html` 은 **랜딩이 구워진 파일**이다 — 여기로 넘기면
|
|
# `/builder` 를 열었는데 랜딩 HTML 이 내려가고, 클라이언트 라우터는 다른 주소로
|
|
# 하이드레이트한다. 화면은 뜨는데 한 번 깜빡이고 마크업이 어긋나는 종류다.
|
|
# 구워진 경로(`/` `/pricing` `/showcase`)는 그 앞의 `$uri/index.html` 이 먼저 잡는다.
|
|
# ★ HTML 은 캐시하지 않는다 — 번들 해시가 박혀 있어서, 캐시되면 새로 배포해도
|
|
# 브라우저가 옛 번들 주소를 계속 부른다(404 → 흰 화면).
|
|
location / {
|
|
root /srv/app;
|
|
try_files $uri $uri/index.html /__spa-fallback.html;
|
|
add_header Cache-Control "no-cache";
|
|
}
|
|
|
|
# 발행되지 않은 주소. 사장님이 오타를 냈을 때 흰 화면 대신 이유를 보여준다.
|
|
error_page 404 /404.html;
|
|
location = /404.html {
|
|
internal;
|
|
return 404 '<!doctype html><html lang="ko"><meta charset="utf-8"><title>페이지를 찾을 수 없습니다</title><body style="font-family:system-ui;padding:3rem;text-align:center"><h1>페이지를 찾을 수 없습니다</h1><p>주소를 다시 확인해 주세요.</p></body></html>';
|
|
add_header Content-Type "text/html; charset=utf-8";
|
|
}
|
|
}
|