diff --git a/.env.example b/.env.example index a3b2fce..dab8ed8 100644 --- a/.env.example +++ b/.env.example @@ -44,7 +44,7 @@ GOOGLE_CLIENT_ID= # ── solution/site 발행물 — solution/backend 도 같이 본다 # canonical·og:url·sitemap·IndexNow 가 전부 SITE_PUBLIC_HOST 를 쓴다. -SITE_PUBLIC_HOST=w4ai.o2o.kr +SITE_PUBLIC_HOST=web4ai.o2osolution.ai INDEXNOW_KEY= # 비우면 색인 통보를 건너뛴다(발행은 정상) AZURE_STORAGE_CONNECTION_STRING= # 비우면 로컬 발행만 한다 AZURE_STORAGE_CONTAINER= diff --git a/AGENTS.md b/AGENTS.md index 87b3819..5652113 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ `azure_static.publish(slug)` 는 공용 자산 + `s/` 만 올린다 — **렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.** → `docker compose restart solution-frontend` 후 `python scripts/republish_all.py` -- **발행 호스트는 두 곳에 있고 같아야 한다.** 백엔드 `SITE_PUBLIC_HOST`(기본 `w4ai.o2o.kr`, +- **발행 호스트는 두 곳에 있고 같아야 한다.** 백엔드 `SITE_PUBLIC_HOST`(기본 `web4ai.o2osolution.ai`, `site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부 이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더 재실행만으로는 안 되고 **백엔드에서 재발행**해 payload 를 다시 만들어야 한다. diff --git a/docker-compose.yml b/docker-compose.yml index 212dea1..f9a02a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,7 @@ x-common-env: &common-env # 수신자(aud)를 대조한다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} # ★ 프론트(VITE_PUBLISH_HOST)와 같은 값이어야 한다. canonical·og:url·sitemap 이 전부 이걸 쓴다. - SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr} + SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-web4ai.o2osolution.ai} SITE_PAYLOAD_DIR: /app/out/payloads SITE_OUTPUT_DIR: /app/out/sites # ★ 프리렌더와 같은 값이어야 한다. 어긋나면 색인 통보가 403 이다. @@ -148,7 +148,7 @@ services: INDEXNOW_KEY: ${INDEXNOW_KEY:-} # ★ 발행 호스트를 프론트 .env 에 따로 적지 않는다 — 루트 .env 의 SITE_PUBLIC_HOST 를 # 그대로 흘려보낸다. 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다. - VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr} + VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-web4ai.o2osolution.ai} volumes: - ./package.json:/app/package.json - ./package-lock.json:/app/package-lock.json @@ -186,7 +186,7 @@ services: environment: PORT: 3001 INDEXNOW_KEY: ${INDEXNOW_KEY:-} - VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr} + VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-web4ai.o2osolution.ai} # ★ 이 둘은 **브라우저가** 부르는 주소다. 컨테이너 안에서 보는 주소가 아니라 # 화면을 연 사람이 닿을 수 있는 주소여야 한다 — localhost 는 서버에 올리는 순간 틀린다. VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800} @@ -259,7 +259,7 @@ services: # ★ VITE_* 는 **번들에 구워진다.** .env 를 고쳐도 재빌드 전엔 안 바뀐다 # → 주소를 바꿨으면 `./deploy.sh solution-site`. VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800} - VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr} + VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-web4ai.o2osolution.ai} VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000} # ⚠️ 비어 있으면 자동 로그인은 아예 꺼진다(기본값 없음). 채우면 번들에 구워진다. VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-} diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index f9466e0..2490f3b 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -91,7 +91,7 @@ docker compose exec solution-worker python scripts/republish_all.py | 어디 | 무엇 | 기본값 | |---|---|---| -| 백엔드 | `SITE_PUBLIC_HOST` (`site_payload.py` 의 `DEFAULT_HOST`) | `w4ai.o2o.kr` | +| 백엔드 | `SITE_PUBLIC_HOST` (`site_payload.py` 의 `DEFAULT_HOST`) | `web4ai.o2osolution.ai` | | 프론트(빌더·`web` 컨테이너) | `VITE_PUBLISH_HOST` | compose 가 루트의 `SITE_PUBLIC_HOST` 를 흘려보낸다 | ★ 프론트 `.env` 에 따로 적지 않는다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다. diff --git a/docs/DEVELOPMENT_DIRECTION.md b/docs/DEVELOPMENT_DIRECTION.md index f754510..3533602 100644 --- a/docs/DEVELOPMENT_DIRECTION.md +++ b/docs/DEVELOPMENT_DIRECTION.md @@ -40,7 +40,7 @@ | 1차 업종 | 숙박, 법무법인, 성형외과 | 숙박, 카페, 음식점, 관광체험 | **반드시 사업 결정 필요.** 법률·의료를 넣으면 데이터 스키마뿐 아니라 규제·승인·감사 체계가 선행되어야 함 | | 입력 방식 | 고객 원본 사이트를 depth 3·최대 200페이지 크롤 | 공식 API, 사용자 확정 URL, 정적 HTML; 플랫폼 우회 수집 금지 | 현재의 출처·동의·robots 원칙 유지. 대규모 원본 사이트 수집은 별도 제품 모드로 분리 | | 동적 크롤링 | 정적 우선, Playwright 폴백 | 봇 탐지 우회로 변질될 수 있어 HeadlessAdapter 미등록·금지 | 포괄적인 Playwright 폴백은 채택하지 않음. 소유권이 검증된 고객 도메인에만 허용할지 법무·보안 결정 후 제한적으로 검토 | -| 발행 도메인 | 고객 도메인 서브패스 권장, 서브도메인 차선 | 기본 `w4ai.o2o.kr/s/`, custom domain 경로 미완성 | 설계서 방향이 검색 권위 측면에서 더 적합. 고객 도메인 연결·소유권 검증을 우선 과제로 추가 | +| 발행 도메인 | 고객 도메인 서브패스 권장, 서브도메인 차선 | 기본 `web4ai.o2osolution.ai/s/`, custom domain 경로 미완성 | 설계서 방향이 검색 권위 측면에서 더 적합. 고객 도메인 연결·소유권 검증을 우선 과제로 추가 | | 백엔드 구조 | Site/Brand 엔진, BFF, 엔진별 DB 분리 | 코드 한 벌 + 진입점 둘(:9800 사장님 / :9801 내부), 단일 PostgreSQL | 청중별 분리는 포트로 끝냈다. 엔진별 물리 분리는 트래픽·팀 소유권 근거가 생긴 뒤 수행 | | 작업 인프라 | Temporal, Redis, Celery 등 공통 인프라 | PostgreSQL 잡 큐 + lease + dead-letter | 현재 DB 큐 유지. 동일 책임의 인프라를 중복 도입하지 않음. 장기 워크플로 보상·분산 추적 요구가 확인될 때 Temporal 재평가 | | Fact Graph | 엔티티·predicate·snapshot, append-only | 업종 스키마 기반 key-value fact, 후보/노출/이력 상태 | 현재 모델은 발행 안전성에 적합. Brand 측정 재현성에 필요한 snapshot과 entity 관계만 점진적으로 확장 | diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 663b230..9541b9e 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,33 @@ --- +## 2026-09-03 — 레포·발행 호스트 교체 — `o2o-site-AEO` / `web4ai.o2osolution.ai` + +**왜** +레포를 `castad/o2o-web4ai` → `Web4ai/o2o-site-AEO` 로, 공개 주소를 `w4ai.o2o.kr` → +`web4ai.o2osolution.ai` 로 옮겼다. 옛 주소는 앞단에 vhost 가 없어 전 경로가 Apache 404 였다 — +그런데 canonical·og:url·sitemap 이 전부 그 주소를 가리키고 있었다. **화면은 멀쩡하고 기계가 +읽는 값만 틀린** 상태라, 검색엔진 등록을 아무리 해도 색인이 안 되는 종류다. + +**바꾼 것** +- 기본 호스트를 쓰는 자리 전부(`site_payload.DEFAULT_HOST` · compose 의 `:-` 기본값 4곳 · + `vite.config.ts` allowedHosts · `.env.example` 둘 · `check_search_ready.py` · 데모 픽스처) +- `docs/SERVERS.md`: 배포 경로 `~/data2/o2o-site-AEO` · 새 remote · 공개 주소 절 +- `init.sql`: `site.sites.thumbnail_url` 을 ALTER 절에 추가 — 아래 참조 +- `solution/frontend/public/google60b514c02fd6af4e.html`: 새 호스트로 다시 받은 구글 소유확인 + +**밟은 함정 둘** +1. **`origin` 은 payload JSON 에 구워진다.** `.env` 만 고치고 프리렌더를 돌리면 안 바뀐다 — + 백엔드에서 재발행하거나 payload 의 `origin` 을 직접 고쳐야 한다. +2. **`init.sql` 은 DB 최초 생성 때만 돈다.** 41커밋을 건너뛰며 배포했더니 `users.provider` 와 + `sites.thumbnail_url` 이 없어 로그인·쇼케이스가 통째로 죽었는데 **HTTP 는 200 이었다.** + `thumbnail_url` 은 `CREATE TABLE` 에만 추가돼 있어서 **새 DB 는 되고 기존 DB 만** 깨졌다. + +**검증** — 새 호스트로 canonical·og:url·robots.txt·sitemap 3건 전부 확인, 로그인·쇼케이스· +장소검색 정상, 스키마 드리프트 0. + +--- + ## 2026-09-03 — 랜딩 · 요금 · 쇼케이스 — 로그인 전 화면이 생겼다 **왜** diff --git a/docs/SERVERS.md b/docs/SERVERS.md index 42af8d2..39f2fa3 100644 --- a/docs/SERVERS.md +++ b/docs/SERVERS.md @@ -35,7 +35,7 @@ Ubuntu 18.04.6 LTS · 24 core · RAM 125G · Docker 24.0.2 · Docker Compose v2. 컨테이너 34개가 이미 돈다 (negosium · iquote · triple-pick · infinith · gitea · persona 등). **우리만 쓰는 서버가 아니다** — 포트와 디스크를 남의 것과 나눠 쓴다. -## 어디에 두나 — `/home/o2oadmin/data2/o2o-web4ai` +## 어디에 두나 — `/home/o2oadmin/data2/o2o-site-AEO` 레포는 홈이 아니라 **`data2` 밑**에 둔다. 홈이 있는 루트 디스크와 다른 물리 디스크다. @@ -45,9 +45,14 @@ Ubuntu 18.04.6 LTS · 24 core · RAM 125G · Docker 24.0.2 · Docker Compose v2. ``` `o2o-negosium` · `o2o-iquote` · `o2o-triple-pick` 이 모두 `/home/o2oadmin/data2/<레포명>` 이다. -같은 규약을 따른다. (`git remote` 는 `https://gitea.o2o.kr/castad/o2o-web4ai.git` — gitea 도 +같은 규약을 따른다. (`git remote` 는 `https://gitea.o2o.kr/Web4ai/o2o-site-AEO.git` — gitea 도 이 서버의 컨테이너다.) +★ 2026-09-03 에 레포를 `castad/o2o-web4ai` 에서 옮겼다. **compose 프로젝트명이 +`name: o2o-web4ai` 로 박혀 있어**(docker-compose.yml:3) 디렉토리를 옮겨도 컨테이너·네트워크· +`site-out` 볼륨 이름이 그대로다 — 그래서 발행 산출물이 살아남는다. 반대로 **두 디렉토리에서 +동시에 `up` 하면 서로 잡아먹는다.** 옛 `~/data2/o2o-web4ai` 는 롤백용으로 남겨 뒀다. + ## 포트 — 이 서버에서 우리가 잡은 자리 ★ **`:80` 은 호스트 nginx 가 이미 물고 있다**(bible-chatbot·o2sound-voucher 를 라우팅 중). @@ -97,7 +102,7 @@ ssh -N -L 3002:127.0.0.1:3002 -L 9801:127.0.0.1:9801 King_admin 레포 루트에 스크립트 두 개가 있다. 서버에서 실행한다. ```bash -cd ~/data2/o2o-web4ai +cd ~/data2/o2o-site-AEO ./deploy.sh # 전체 (git pull → build → up -d) ./deploy.sh api # 그 서비스만 ./log.sh # 1=전체, 2번부터 개별 컨테이너 @@ -107,8 +112,8 @@ cd ~/data2/o2o-web4ai ★ `deploy.sh api` 는 worker·api-admin 도 함께 갈아끼운다. 셋이 이미지 한 벌을 나눠 쓰기 때문이다 — 안 그러면 옛 코드로 도는 컨테이너가 남는데 셋 다 "살아 있음" 이라 눈으로는 구분이 안 된다. -★ 서버에 gitea 자격증명이 없어 `git pull` 이 실패한다. 밖에서 밀어넣고 `./deploy.sh --no-pull` -로 돌리거나, gitea 에 배포키를 등록해 원격을 SSH 로 바꾼다. +★ 새 클론(`o2o-site-AEO`)은 gitea 자격증명이 통해서 `./deploy.sh` 를 서버에서 그대로 쓴다. +옛 `o2o-web4ai` 는 안 됐다 — fetch 가 죽으면 deploy.sh 가 리셋을 건너뛰고 디스크 코드로 간다. ## DB @@ -117,13 +122,22 @@ cd ~/data2/o2o-web4ai 붙여 두었으므로 **컴포즈를 고치지 않고 그대로 닿는다.** DB 는 만들어야 한다 — `postgres-init/init-data/init.sql` 한 벌이 스키마 전부다. -## 공개 주소 — `https://w4ai.o2o.kr` (2026-09-01 연결됨) +★ **`init.sql` 은 DB 를 처음 만들 때만 돈다.** 이미 있는 DB 에는 파일 하단의 "기존 DB 보정(ALTER)" +절만 손으로 돌려야 새 컬럼이 들어간다. 빠뜨리면 **HTTP 는 200 인데 기능만 죽는다** — +실측(2026-09-03): `users.provider` 없음 → 로그인 전부 실패, `sites.thumbnail_url` 없음 → +쇼케이스 전부 실패. 로그를 봐야 보인다. 컬럼을 `CREATE TABLE` 에만 추가하고 ALTER 절에 +안 적으면 **새 DB 는 되고 기존 DB 만 조용히 깨진다.** + +## 공개 주소 — `https://web4ai.o2osolution.ai` (2026-09-03 기준) ``` -DNS A w4ai.o2o.kr → 59.14.81.3 +DNS A web4ai.o2osolution.ai → 59.14.81.3 └ 앞단 Apache(2.4.18) → http://172.30.1.36:30030 → solution-site ``` +★ 옛 `w4ai.o2o.kr` 은 **쓰지 않는다.** DNS 는 아직 살아 있지만 앞단에 vhost 가 없어 +전 경로가 Apache 자체 404 다(인증서도 `CN=actions.o2o.kr`, 2024 만료). + `59.14.81.3` 은 사내망 입구다(`gitea.o2o.kr` 과 같은 IP). 킹서버의 `172.30.1.36` 은 **사설 IP라 DNS 에 못 적는다** — 앞단 vhost 와 인증서는 이 레포 밖이고 인프라 담당이 잡는다. diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 308d123..98b42e5 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -444,3 +444,7 @@ ALTER TABLE company.users ALTER COLUMN password DROP NOT NULL; -- 생기므로, 인덱스를 먼저 만들면 "column provider_uid does not exist" 로 스크립트가 통째로 멈춘다. CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON company.users (provider, provider_uid) WHERE deleted = FALSE AND provider_uid IS NOT NULL; + +-- 2026-09-03 쇼케이스 카드 썸네일. CREATE TABLE 에만 있어서 기존 DB 가 조용히 깨졌다 +-- (실측: 킹서버에서 GET /v1/showcase 가 200 인데 내용은 비었다). +ALTER TABLE site.sites ADD COLUMN IF NOT EXISTS thumbnail_url VARCHAR(500) NULL; diff --git a/solution/backend/scripts/check_search_ready.py b/solution/backend/scripts/check_search_ready.py index f39fbdc..3678311 100644 --- a/solution/backend/scripts/check_search_ready.py +++ b/solution/backend/scripts/check_search_ready.py @@ -1,7 +1,7 @@ """발행본이 **인터넷에서** 검색엔진에 읽힐 준비가 됐는지 확인한다. - python scripts/check_search_ready.py (기본 https://w4ai.o2o.kr) - python scripts/check_search_ready.py https://w4ai.o2o.kr + python scripts/check_search_ready.py (기본 https://web4ai.o2osolution.ai) + python scripts/check_search_ready.py https://web4ai.o2osolution.ai python scripts/check_search_ready.py --slug butter (특정 사이트만) ★ 왜 필요한가 — 색인 여부는 며칠~몇 주 뒤에나 알 수 있다. 그때까지 기다렸다가 @@ -171,7 +171,7 @@ def check_crawler_access(client: httpx.Client, origin: str, slug: str) -> None: # ── 실행 ───────────────────────────────────────────────────────────────── def main() -> None: parser = argparse.ArgumentParser() - parser.add_argument("origin", nargs="?", default="https://w4ai.o2o.kr", help="확인할 오리진") + parser.add_argument("origin", nargs="?", default="https://web4ai.o2osolution.ai", help="확인할 오리진") parser.add_argument("--slug", help="이 사이트만 확인(기본: 사이트맵의 첫 사이트)") args = parser.parse_args() origin = args.origin.rstrip("/") diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index d41157d..ee3d827 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -45,7 +45,7 @@ DEFAULT_PAYLOAD_DIR = "/app/out/payloads" # 검색엔진에는 열리지도 않는 주소가 등록된다(조용히 틀린다 — 아무도 눈치채지 못한다). # 프론트도 같은 이유로 VITE_PUBLISH_HOST 를 쓴다. 두 값은 **같아야 한다**. SITE_HOST_ENV = "SITE_PUBLIC_HOST" -DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "w4ai.o2o.kr" +DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "web4ai.o2osolution.ai" # 링크 제목이 비었을 때 채우는 채널 이름. 없는 채널명을 지어내지 않기 위한 고정 표다. _CHANNEL_TITLE = { diff --git a/solution/frontend/.env.example b/solution/frontend/.env.example index 872ce9c..d4c1074 100644 --- a/solution/frontend/.env.example +++ b/solution/frontend/.env.example @@ -5,7 +5,7 @@ VITE_API_BASE_URL=http://localhost:9800 # 커스텀 도메인이 붙기 전까지 .<이 값> 형태로 나간다. # ★ 로컬에서 compose 없이 띄울 때만 쓴다. compose 로 띄우면 루트 .env 의 SITE_PUBLIC_HOST 가 # 주입돼 이 값을 덮는다 — 발행 호스트의 단일 출처는 루트 .env 다. -VITE_PUBLISH_HOST=w4ai.o2o.kr +VITE_PUBLISH_HOST=web4ai.o2osolution.ai # 발행 사이트 렌더러 개발 서버. 에디터 상단 "발행본 사이트 열기" 가 이 주소를 연다. VITE_SITE_PREVIEW_URL=http://localhost:3000 diff --git a/solution/frontend/vite.config.ts b/solution/frontend/vite.config.ts index 3bf9e1e..66f41dd 100644 --- a/solution/frontend/vite.config.ts +++ b/solution/frontend/vite.config.ts @@ -28,7 +28,7 @@ export default defineConfig({ // ★ Vite 6 는 모르는 Host 헤더를 403 "Blocked request" 로 막는다(DNS rebinding 방어). // 운영은 앞단 nginx 가 Host 를 그대로 넘기므로 발행 호스트를 여기 넣어야 화면이 뜬다. // 빠뜨리면 프록시는 정상인데 앱만 전부 403 이다. - allowedHosts: [process.env.VITE_PUBLISH_HOST ?? 'w4ai.o2o.kr'], + allowedHosts: [process.env.VITE_PUBLISH_HOST ?? 'web4ai.o2osolution.ai'], // 발행 사이트는 별도 정적 서버(:3001)가 만들고 서빙하지만, 사용자는 admin 과 같은 // origin(:3000)의 `/s/` 로 접근한다. 프로세스 포트만 분리하고 공개 URL은 하나로 둔다. proxy: { diff --git a/solution/shared/src/types/site-payload.ts b/solution/shared/src/types/site-payload.ts index 08f5801..ccd217c 100644 --- a/solution/shared/src/types/site-payload.ts +++ b/solution/shared/src/types/site-payload.ts @@ -52,7 +52,7 @@ export interface SiteMeta { siteId: string; placeId: string; status: SiteStatus; - /** 발행 도메인. 커스텀 도메인이 없으면 `.w4ai.o2o.kr` 같은 기본 호스트. */ + /** 발행 도메인. 커스텀 도메인이 없으면 `.web4ai.o2osolution.ai` 같은 기본 호스트. */ origin: string; /** URL 경로 프리픽스. 루트 발행이면 '' (빈 문자열). */ basePath: string; diff --git a/solution/site/src/fixtures/moonlight-stay.ts b/solution/site/src/fixtures/moonlight-stay.ts index 43b37ac..ca32309 100644 --- a/solution/site/src/fixtures/moonlight-stay.ts +++ b/solution/site/src/fixtures/moonlight-stay.ts @@ -93,7 +93,7 @@ export const MOONLIGHT_STAY_PAYLOAD: SitePayload = { siteId: 'demo-site-0001', placeId: 'demo-place-0001', status: SiteStatus.PUBLISHED, - origin: 'https://moonlight-stay-jeju.w4ai.o2o.kr', + origin: 'https://moonlight-stay-jeju.web4ai.o2osolution.ai', basePath: '', slug: 'moonlight-stay-jeju', publishedAt: '2026-08-20T10:00:00+09:00',