From d6118165c41bfc51c1a4febe4e347d1ade018de1 Mon Sep 17 00:00:00 2001
From: Mina Choi
Date: Thu, 3 Sep 2026 11:25:11 +0900
Subject: [PATCH 1/4] =?UTF-8?q?[chore]=20solution/frontend:=20=EA=B5=AC?=
=?UTF-8?q?=EA=B8=80=20=EC=86=8C=EC=9C=A0=ED=99=95=EC=9D=B8=20=ED=8C=8C?=
=?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EA=B0=80=20=E2=80=94=20=EC=83=88=20?=
=?UTF-8?q?=ED=98=B8=EC=8A=A4=ED=8A=B8=20=EA=B8=B0=EC=A4=80=EC=9C=BC?=
=?UTF-8?q?=EB=A1=9C=20=EB=8B=A4=EC=8B=9C=20=EB=B0=9B=EC=9D=80=20=EA=B2=83?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
발행 호스트를 web4ai.o2osolution.ai 로 옮기면서 w4ai.o2o.kr 로 받아 둔 소유확인이
전부 무효가 됐다. HTML 파일 방식으로 다시 받았다.
- public/google60b514c02fd6af4e.html: Bing 것과 같은 자리다. 이미지에 구워야
컨테이너 재생성에도 살아남는다(docker cp 로 넣으면 다음 배포에 사라진다)
확인은 상태코드가 아니라 **내용**으로 한다 — nginx 가 없는 경로를 index.html 로
떨어뜨려 200 을 준다.
---
solution/frontend/public/google60b514c02fd6af4e.html | 1 +
1 file changed, 1 insertion(+)
create mode 100644 solution/frontend/public/google60b514c02fd6af4e.html
diff --git a/solution/frontend/public/google60b514c02fd6af4e.html b/solution/frontend/public/google60b514c02fd6af4e.html
new file mode 100644
index 0000000..968a1e3
--- /dev/null
+++ b/solution/frontend/public/google60b514c02fd6af4e.html
@@ -0,0 +1 @@
+google-site-verification: google60b514c02fd6af4e.html
\ No newline at end of file
From bce0928385e409baec26448604db3e7f0168271d Mon Sep 17 00:00:00 2001
From: Mina Choi
Date: Thu, 3 Sep 2026 11:31:34 +0900
Subject: [PATCH 2/4] =?UTF-8?q?[chore]=20deploy,solution,docs:=20=EB=A0=88?=
=?UTF-8?q?=ED=8F=AC=C2=B7=EB=B0=9C=ED=96=89=20=ED=98=B8=EC=8A=A4=ED=8A=B8?=
=?UTF-8?q?=20=EA=B5=90=EC=B2=B4=20=E2=80=94=20o2o-site-AEO=20/=20web4ai.o?=
=?UTF-8?q?2osolution.ai?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
옛 주소 w4ai.o2o.kr 은 앞단에 vhost 가 없어 전 경로가 Apache 자체 404 다(인증서도
CN=actions.o2o.kr, 2024 만료). 그런데 canonical·og:url·sitemap 이 전부 그 주소를
가리키고 있었다 — **화면은 멀쩡하고 기계가 읽는 값만 틀린** 상태라, 검색엔진에
아무리 등록해도 색인이 안 되는 종류다.
- 기본 호스트를 쓰는 자리 전부: site_payload.DEFAULT_HOST · compose 의 `:-` 기본값 4곳 ·
vite.config.ts allowedHosts · .env.example 둘 · check_search_ready.py · 데모 픽스처
- init.sql: site.sites.thumbnail_url 을 "기존 DB 보정(ALTER)" 절에 추가.
CREATE TABLE 에만 있어서 **새 DB 는 되고 기존 DB 만 조용히 깨졌다** —
실측(킹서버): GET /v1/showcase 가 200 인데 내용이 비었다
- docs/SERVERS.md: 배포 경로 ~/data2/o2o-site-AEO · 새 remote · 공개 주소 절 ·
init.sql 이 DB 최초 생성 때만 돈다는 함정
- docs/DEVLOG.md: 항목 추가
테스트 픽스처의 w4ai.o2o.kr 은 그대로 뒀다 — 자기가 넣은 값을 자기가 검증해서
기본 호스트와 무관하다.
tsc·eslint 통과. vite build 는 도커에서 확인(로컬 node_modules 의 rollup 네이티브 누락).
---
.env.example | 2 +-
AGENTS.md | 2 +-
docker-compose.yml | 8 +++---
docs/DEPLOY.md | 2 +-
docs/DEVELOPMENT_DIRECTION.md | 2 +-
docs/DEVLOG.md | 27 ++++++++++++++++++
docs/SERVERS.md | 28 ++++++++++++++-----
postgres-init/init-data/init.sql | 4 +++
.../backend/scripts/check_search_ready.py | 6 ++--
solution/backend/services/site_payload.py | 2 +-
solution/frontend/.env.example | 2 +-
solution/frontend/vite.config.ts | 2 +-
solution/shared/src/types/site-payload.ts | 2 +-
solution/site/src/fixtures/moonlight-stay.ts | 2 +-
14 files changed, 68 insertions(+), 23 deletions(-)
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',
From a67169dfa25e270b5c0c2830d3e825f3fc423d28 Mon Sep 17 00:00:00 2001
From: Mina Choi
Date: Thu, 3 Sep 2026 11:32:58 +0900
Subject: [PATCH 3/4] =?UTF-8?q?[chore]=20solution/frontend:=20=EB=84=A4?=
=?UTF-8?q?=EC=9D=B4=EB=B2=84=20=EC=86=8C=EC=9C=A0=ED=99=95=EC=9D=B8=20?=
=?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80=20=E2=80=94=20=EC=84=B8?=
=?UTF-8?q?=20=EC=97=94=EC=A7=84=20=EC=A4=91=20=EB=A7=88=EC=A7=80=EB=A7=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
네이버 서치어드바이저는 DNS TXT 를 안 받는다. HTML 파일 아니면 메타태그뿐이라
구글·빙과 같은 자리에 둔다.
- public/naver71762ee96e2e126623dc1da07ecae493.html
★ 확인은 상태코드가 아니라 **내용**으로 한다 — nginx 의 `try_files ... /index.html` 이
없는 경로에 SPA 를 200 으로 돌려준다.
---
.../frontend/public/naver71762ee96e2e126623dc1da07ecae493.html | 1 +
1 file changed, 1 insertion(+)
create mode 100644 solution/frontend/public/naver71762ee96e2e126623dc1da07ecae493.html
diff --git a/solution/frontend/public/naver71762ee96e2e126623dc1da07ecae493.html b/solution/frontend/public/naver71762ee96e2e126623dc1da07ecae493.html
new file mode 100644
index 0000000..75dbd8b
--- /dev/null
+++ b/solution/frontend/public/naver71762ee96e2e126623dc1da07ecae493.html
@@ -0,0 +1 @@
+naver-site-verification: naver71762ee96e2e126623dc1da07ecae493.html
\ No newline at end of file
From 238d4c25c4e9e54645d0d540b780ad79e3a599d4 Mon Sep 17 00:00:00 2001
From: Mina Choi
Date: Thu, 3 Sep 2026 16:37:12 +0900
Subject: [PATCH 4/4] =?UTF-8?q?[feat]=20solution:=20=EB=84=A4=EC=9D=B4?=
=?UTF-8?q?=EB=B2=84=20=ED=94=8C=EB=A0=88=EC=9D=B4=EC=8A=A4=EB=A5=BC=20?=
=?UTF-8?q?=EA=B2=80=EC=83=89=20=EB=8B=A8=EA=B3=84=EC=97=90=EC=84=9C=20?=
=?UTF-8?q?=EC=B0=BE=EB=8A=94=EB=8B=A4=20=E2=80=94=20=EB=A1=9C=EA=B7=B8?=
=?UTF-8?q?=EC=9D=B8=EC=9D=80=20=EC=88=98=EC=A7=91=20=EC=A7=81=EC=A0=84=20?=
=?UTF-8?q?=ED=95=9C=20=EB=B2=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
사장님이 후보를 고른 뒤에도 "네이버 플레이스를 자동으로 찾지 못했습니다" 가 떴다.
찾을 수 있는데도 그랬다 — 자동 발견이 확정 경로(로그인 뒤)에만 있었고, 공개 검색은
상호·주소만 돌려줬다. 그리고 확정이 사업장 생성을 요구해서, 로그인 없이 시작하기로 한
위저드가 검색 직후부터 막혔다(자동 로그인이 그걸 가리고 있었다).
- place_service.search_places_public: 응답에 naver_place_url 을 싣는다. 넓은 검색어
한 페이지에서 못 찾은 후보는 그 후보만 겨냥해 다시 찾는다(상위 2건, 429 회피).
실측: 12개 상호 전부 발견. 전에는 4개 중 2개
- naver_place_lookup._region_hint: 주소에서 시·군·구까지만 뽑아 검색을 좁힌다.
첫 토막('경기도')만 쓰면 **다른 동네 동명 업소**가 잡히고, 그 id 로 검증하면 남의
가게가 이 사이트의 기준 정보가 된다 — 실측으로 한 번 겪었다
- ttl_cache(신규) + 공개 검색 10분 캐시: 검색 1회가 네이버를 최대 3번 긁는데 인증이
없어 새로고침만으로 나간다. 실측 1.38s → 0.005s. **빈 결과는 캐시하지 않는다** —
일시적 0건을 굳히면 사장님이 10분간 막힌다
- 확정은 서버를 부르지 않는다(usePlaceSearch). 화면에만 남기고, 수집 직전 로그인 뒤
ensureServerPlace 가 생성 → 검증을 한 번에 한다. 나눠 두면 "사업장은 생겼는데 검증이
빠진" 상태가 생기고 수집이 PLACE_NOT_VERIFIED 로 조용히 거절된다
- Step3: 수집 버튼이 로그인 모달을 연다(/login 으로 튕기지 않는다 — 위저드 상태가
주소창에 없어 돌아올 길이 없다). 로그인하면 이어서 돈다
- Step2: 후보 카드에 '네이버 플레이스 찾음' 배지. 붙여넣기 칸은 접는다 —
펼쳐 두면 시도도 전에 실패한 것으로 읽힌다. 뒤로 오면 처음 화면으로
- ChannelUrlInput: [추가] → [이 주소로 가져오기]. 로그인 전에는 addLink 가 placeId 가
없어 **조용히 return** 해서 입력칸만 비워졌다(useChannelLinks.ts:37)
- LoginPage: admin/1234 기본값 제거. 배포 번들에 그대로 나가 있었다
- 기본 발행 호스트를 localhost 로(compose 4곳 · site_payload.DEFAULT_HOST · .env.example).
운영 도메인을 기본값으로 두면 .env 를 안 채운 로컬 빌드가 조용히 운영 주소를 번들에
굽는다 — 실측: 로컬에서 만든 링크가 킹서버로 갔다. localhost 는 http 로 조립한다
검증: tsc·eslint·vite build 통과. 브라우저로 전 구간 확인(검색 → 확정 → 로그인 →
자동 발견 → 검증 → 수집 fact 27건·사진 10장·메뉴 23건 → 사진 분석).
백엔드 테스트는 이 워크트리에서 못 돌렸다 — config.test.toml 이 없어 DB 인증이 실패한다.
---
.env.example | 3 +-
docker-compose.yml | 16 +-
solution/backend/common/utils/ttl_cache.py | 36 ++++
solution/backend/router/v1/place/protocol.py | 3 +
.../services/external/naver_place_lookup.py | 16 +-
solution/backend/services/place_service.py | 58 +++++-
solution/backend/services/site_payload.py | 13 +-
solution/frontend/.env.example | 2 +-
.../api/generated/model/placeSearchItem.ts | 2 +
.../features/onboarding/ChannelUrlInput.tsx | 27 ++-
.../features/onboarding/Step2PlaceSearch.tsx | 103 +++++++++--
.../features/onboarding/Step3DataReview.tsx | 173 ++++++++++++++++--
.../features/onboarding/ensureServerPlace.ts | 81 ++++++++
.../src/features/onboarding/usePlaceSearch.ts | 110 +++--------
solution/frontend/src/pages/LoginPage.tsx | 7 +-
solution/frontend/src/stores/builderTypes.ts | 3 +
16 files changed, 524 insertions(+), 129 deletions(-)
create mode 100644 solution/backend/common/utils/ttl_cache.py
create mode 100644 solution/frontend/src/features/onboarding/ensureServerPlace.ts
diff --git a/.env.example b/.env.example
index dab8ed8..950d8b1 100644
--- a/.env.example
+++ b/.env.example
@@ -44,7 +44,8 @@ GOOGLE_CLIENT_ID=
# ── solution/site 발행물 — solution/backend 도 같이 본다
# canonical·og:url·sitemap·IndexNow 가 전부 SITE_PUBLIC_HOST 를 쓴다.
-SITE_PUBLIC_HOST=web4ai.o2osolution.ai
+# 로컬은 비워 둔다(기본값 localhost). 서버에 올릴 때만 실제 도메인을 적는다.
+# SITE_PUBLIC_HOST=web4ai.o2osolution.ai
INDEXNOW_KEY= # 비우면 색인 통보를 건너뛴다(발행은 정상)
AZURE_STORAGE_CONNECTION_STRING= # 비우면 로컬 발행만 한다
AZURE_STORAGE_CONTAINER=
diff --git a/docker-compose.yml b/docker-compose.yml
index f9a02a1..59f677a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -25,7 +25,9 @@ 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:-web4ai.o2osolution.ai}
+ # ★ 기본값은 localhost 다. 운영 도메인을 기본으로 두면 .env 를 안 채운 로컬 빌드가
+ # 조용히 운영 주소를 번들에 굽는다(실측 2026-09-03: 로컬 링크가 킹서버로 갔다).
+ SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-localhost}
SITE_PAYLOAD_DIR: /app/out/payloads
SITE_OUTPUT_DIR: /app/out/sites
# ★ 프리렌더와 같은 값이어야 한다. 어긋나면 색인 통보가 403 이다.
@@ -148,7 +150,7 @@ services:
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
# ★ 발행 호스트를 프론트 .env 에 따로 적지 않는다 — 루트 .env 의 SITE_PUBLIC_HOST 를
# 그대로 흘려보낸다. 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-web4ai.o2osolution.ai}
+ VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
volumes:
- ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json
@@ -186,11 +188,11 @@ services:
environment:
PORT: 3001
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
- VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-web4ai.o2osolution.ai}
+ VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
# ★ 이 둘은 **브라우저가** 부르는 주소다. 컨테이너 안에서 보는 주소가 아니라
# 화면을 연 사람이 닿을 수 있는 주소여야 한다 — localhost 는 서버에 올리는 순간 틀린다.
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800}
- VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000}
+ VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-}
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
# 백엔드와 같은 값을 흘려보낸다(루트 .env 가 단일 출처).
@@ -231,7 +233,7 @@ services:
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}
+ VITE_SOLUTION_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
volumes:
- ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json
@@ -259,8 +261,8 @@ services:
# ★ VITE_* 는 **번들에 구워진다.** .env 를 고쳐도 재빌드 전엔 안 바뀐다
# → 주소를 바꿨으면 `./deploy.sh solution-site`.
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800}
- VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-web4ai.o2osolution.ai}
- VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000}
+ VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
+ VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
# ⚠️ 비어 있으면 자동 로그인은 아예 꺼진다(기본값 없음). 채우면 번들에 구워진다.
VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-}
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
diff --git a/solution/backend/common/utils/ttl_cache.py b/solution/backend/common/utils/ttl_cache.py
new file mode 100644
index 0000000..26fc4b9
--- /dev/null
+++ b/solution/backend/common/utils/ttl_cache.py
@@ -0,0 +1,36 @@
+"""TTL 캐시 — 프로세스 메모리에만 있다. rate_limit 과 같은 한계를 갖는다.
+
+★ 두는 이유는 속도가 아니라 **차단**이다. 공개 검색 1회가 네이버를 최대 3번 긁는데
+ (넓은 검색 1 + 겨냥 2), 인증 없는 경로라 새로고침만으로도 나간다.
+ 실측(2026-09-03): 테스트를 반복하다 m.place.naver.com 에서 429 를 받았다.
+"""
+import time
+from typing import Any, Optional
+
+_store: dict[str, tuple[float, Any]] = {}
+_MAX_KEYS = 500
+
+
+def get(key: str) -> Optional[Any]:
+ hit = _store.get(key)
+ if not hit:
+ return None
+ if time.monotonic() >= hit[0]:
+ _store.pop(key, None)
+ return None
+ return hit[1]
+
+
+def put(key: str, value: Any, ttl_sec: float) -> None:
+ # 만료된 것부터 버리고, 그래도 넘치면 가장 먼저 만료될 것을 버린다.
+ if len(_store) >= _MAX_KEYS:
+ now = time.monotonic()
+ for k in [k for k, (exp, _) in _store.items() if exp <= now]:
+ _store.pop(k, None)
+ if len(_store) >= _MAX_KEYS:
+ _store.pop(min(_store, key=lambda k: _store[k][0]), None)
+ _store[key] = (time.monotonic() + ttl_sec, value)
+
+
+def clear() -> None:
+ _store.clear()
diff --git a/solution/backend/router/v1/place/protocol.py b/solution/backend/router/v1/place/protocol.py
index e9edf4e..2fb75ea 100644
--- a/solution/backend/router/v1/place/protocol.py
+++ b/solution/backend/router/v1/place/protocol.py
@@ -223,6 +223,9 @@ class PlaceSearchItem(WebPacketProtocol):
# 추정 업종. ★ None 이면 못 정한 것이다 — 화면이 사장님에게 직접 고르게 한다.
# 값이 있어도 확정이 아니다. 화면은 언제나 바꿀 수 있게 둔다(경계 업종이 실제로 있다).
category: Optional[PlaceCategory] = None
+ # 자동으로 찾은 네이버 플레이스 주소. ★ 공개 페이지에서 읽은 값이라 우리 DB 것이 아니다.
+ # 못 찾으면 None — 화면이 그때만 사장님에게 지도 주소를 묻는다.
+ naver_place_url: Optional[str] = None
class Res_PlaceSearch(Res_WebPacketProtocol):
diff --git a/solution/backend/services/external/naver_place_lookup.py b/solution/backend/services/external/naver_place_lookup.py
index b8a9db4..1efe49a 100644
--- a/solution/backend/services/external/naver_place_lookup.py
+++ b/solution/backend/services/external/naver_place_lookup.py
@@ -52,6 +52,20 @@ def _normalize(text: str) -> str:
return re.sub(r"[\s,·.\-_'\"()&]", "", (text or "")).lower()
+def _region_hint(address: Optional[str]) -> str:
+ """주소에서 검색을 좁힐 지역 토막. 시/군/구까지만 쓴다.
+
+ ★ 첫 토막만 쓰면 안 된다 — 그건 광역시·도('경기도')라 오히려 넓어진다.
+ 실측(2026-09-03 '버터브루'): '버터브루 경기도' 로 찾으면 **다른 동네 동명 업소**의
+ id 가 잡히고, 그 id 로 검증하면 남의 가게가 이 사이트의 기준 정보가 된다.
+ '버터브루 성남시 중원구' 로 좁히면 정확히 잡힌다.
+ ★ 도로명·번지는 넣지 않는다. 검색 결과가 그 주소를 언급한 블로그로 채워져 id 가 사라진다.
+ """
+ tokens = (address or "").split()
+ picked = [t for t in tokens if t.endswith(("시", "군", "구"))]
+ return " ".join(picked[:2])
+
+
async def _fetch_search_html(query: str) -> Optional[str]:
"""통합검색 결과 페이지 원문. 실패는 None — 호출측이 조용히 폴백한다."""
try:
@@ -114,7 +128,7 @@ async def find_place_id(name: str, address: Optional[str] = None) -> Optional[st
★ 이름이 일치하는 후보만 받는다. '비슷한 것 중 첫 번째'를 고르면 남의 가게를
이 가게의 공식 채널로 등록하게 된다 — 이 제품에서 가장 비싼 실수다.
"""
- query = " ".join(x for x in (name, (address or "").split()[0] if address else "") if x)
+ query = " ".join(x for x in (name, _region_hint(address)) if x)
html = await _fetch_search_html(query)
if not html:
return None
diff --git a/solution/backend/services/place_service.py b/solution/backend/services/place_service.py
index 385fde5..ef78d95 100644
--- a/solution/backend/services/place_service.py
+++ b/solution/backend/services/place_service.py
@@ -50,6 +50,12 @@ from services.external.naver import region_key
from services.job_service import enqueue_job
+# 겨냥 조회를 몇 건까지 할지. 후보 전부(5건)를 부르면 통합검색이 429 를 준다.
+_TARGETED_LOOKUP_LIMIT = 2
+# 공개 검색 캐시 수명. 가게 정보가 이 안에 바뀔 일은 없다.
+_PUBLIC_SEARCH_CACHE_SEC = 600
+
+
class PlaceService:
"""사업장 등록·조회·동일 업소 검증.
@@ -605,9 +611,10 @@ class PlaceService:
★ DB 를 읽지도 쓰지도 않는다. 나가는 값은 외부 장소 DB 가 공개적으로 주는 것뿐이다.
"""
from common.enums import ExternalPlaceSource
- from common.utils import rate_limit
+ from common.utils import rate_limit, ttl_cache
from services.external import kakao as kakao_client
from services.external import naver as naver_client
+ from services.external import naver_place_lookup
from services.place_category import guess_category
res = Res_PlaceSearch()
@@ -616,6 +623,14 @@ class PlaceService:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
return res
+ # 같은 검색어는 캐시로 받는다. 사장님이 상호를 고쳐 가며 대여섯 번 치는 동안
+ # 네이버를 매번 세 번씩 긁지 않게 한다.
+ cache_key = f"place-search:{q}"
+ cached = ttl_cache.get(cache_key)
+ if cached is not None:
+ res.source, res.items = cached
+ return res
+
# ★ 인증이 없는데 유료 외부 API 를 부른다 — 방어가 0 이면 새로고침만으로 요금이 나간다.
# 프로세스 메모리 기반이라 완전하지 않다(common/utils/rate_limit.py 주석).
if not rate_limit.allow(f"place-search:{client_ip}", _PUBLIC_SEARCH_PER_MIN, 60.0):
@@ -644,6 +659,23 @@ class PlaceService:
res.result.SetResult(ErrorType.LOCAL_FETCH_FAILED)
return res
+ # ★ 여기서 네이버 플레이스 주소까지 찾아 실어 보낸다.
+ # 전에는 확정 경로(verify/candidates)에서만 찾았는데, 그건 로그인 뒤라 사장님이
+ # 후보를 고를 때는 "이 가게의 플레이스가 있는지" 를 알 수 없었다. 화면은 그걸
+ # "자동으로 못 찾았다" 로 읽고 지도 주소를 물었다 — 찾을 수 있는데도.
+ # 실패는 조용히 넘긴다(None). 유료 API 가 아니라 공개 페이지 조회다.
+ naver_ids: dict[str, str] = {}
+ try:
+ naver_ids = await naver_place_lookup.find_place_ids(q, [r.name for r in rows])
+ for row in rows[:_TARGETED_LOOKUP_LIMIT]:
+ if row.name in naver_ids:
+ continue
+ found = await naver_place_lookup.find_place_id(row.name, row.road_address or row.address)
+ if found:
+ naver_ids[row.name] = found
+ except Exception as ex: # noqa: BLE001 — 지도 주소 붙여넣기로 이어진다
+ LOG.w(f"[search] 플레이스 자동 발견 실패(후보는 유지): {type(ex).__name__}: {ex}")
+
res.items = [
PlaceSearchItem(
name=row.name,
@@ -651,11 +683,19 @@ class PlaceService:
category_name=row.category_name,
# 네이버는 그룹코드를 주지 않는다 — 그때는 분류 문자열만으로 추정한다.
category=guess_category(row.category_name, getattr(row, "category_group_code", None)),
+ naver_place_url=(
+ naver_place_lookup.place_url(naver_ids[row.name]) if row.name in naver_ids else None
+ ),
)
for row in rows
]
if not res.items:
+ # ★ 빈 결과는 캐시하지 않는다. 네이버가 잠깐 막아서 0건이 나온 것을 굳히면
+ # 사장님은 TTL 이 끝날 때까지 아무것도 못 한다.
res.result.SetResult(ErrorType.PLACE_VERIFY_NO_CANDIDATE)
+ return res
+
+ ttl_cache.put(cache_key, (res.source, res.items), _PUBLIC_SEARCH_CACHE_SEC)
return res
async def find_candidates(self, user_info: UserInfo, place_id: str, query: str | None = None) -> Res_VerifyCandidates:
@@ -731,6 +771,22 @@ class PlaceService:
except Exception as ex: # noqa: BLE001 — 지도 URL 직접 입력으로 이어진다
LOG.w(f"[verify] 네이버 플레이스 자동 발견 실패(후보는 유지): {type(ex).__name__}: {ex}")
naver_ids = {}
+ # ★ 넓은 검색어 한 페이지에서 못 찾은 후보는 **그 후보만 겨냥해** 한 번 더 찾는다.
+ # 넓은 검색은 결과가 다른 지점으로 채워져 이름이 아예 안 실릴 때가 있다 —
+ # 실측(2026-09-03 '스타벅스 판교역점'): 페이지에 id 6개가 있는데 지역검색이 준
+ # 5개 지점명은 하나도 그 근처에 없었다. 상호+지역으로 겨냥하면 4/4 로 찾는다.
+ # 호출은 상위 후보 몇 건으로 끊는다 — 5건을 다 부르면 429 를 받는다.
+ for c in rows[:_TARGETED_LOOKUP_LIMIT]:
+ if c.name in naver_ids:
+ continue
+ try:
+ found = await naver_place_lookup.find_place_id(c.name, c.road_address or c.address)
+ except Exception as ex: # noqa: BLE001 — 지도 URL 직접 입력으로 이어진다
+ LOG.w(f"[verify] 겨냥 조회 실패({c.name}): {type(ex).__name__}: {ex}")
+ break
+ if found:
+ naver_ids[c.name] = found
+
res.candidates = [
PlaceCandidate(
external_place_id=getattr(c, "kakao_place_id", None) or getattr(c, "naver_place_id", None),
diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py
index ee3d827..1281900 100644
--- a/solution/backend/services/site_payload.py
+++ b/solution/backend/services/site_payload.py
@@ -15,6 +15,7 @@
(channel 링크만 스냅샷에 없어서 호출측이 읽어 넘긴다.)
"""
import json
+import re
import os
import unicodedata
from datetime import datetime, timezone
@@ -45,7 +46,13 @@ 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 "web4ai.o2osolution.ai"
+# 기본값은 localhost. 운영 도메인을 기본으로 두면 설정을 빠뜨린 환경이 조용히 운영 주소로
+# canonical·sitemap 을 굽는다 — 틀렸다는 걸 아무도 모른다.
+DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "localhost"
+
+def _scheme(host: str) -> str:
+ """localhost 는 http 다. shared/lib/slug.ts publishUrl 과 같은 규칙."""
+ return "http" if re.match(r"^(localhost|127\.0\.0\.1)(:\d+)?$", host) else "https"
# 링크 제목이 비었을 때 채우는 채널 이름. 없는 채널명을 지어내지 않기 위한 고정 표다.
_CHANNEL_TITLE = {
@@ -550,7 +557,7 @@ def _publish_target(site, place_id: str, name: str) -> dict:
name_slug = slugify(name)
slug = f"{name_slug}-{place_id[:8]}" if name_slug else f"place-{place_id[:8]}"
- return {"origin": f"https://{DEFAULT_HOST}", "basePath": f"/s/{slug}", "slug": slug}
+ return {"origin": f"{_scheme(DEFAULT_HOST)}://{DEFAULT_HOST}", "basePath": f"/s/{slug}", "slug": slug}
def publish_slug(place, site) -> str:
@@ -565,7 +572,7 @@ def publish_slug(place, site) -> str:
def publish_origin() -> str:
"""발행본이 사는 오리진. 썸네일 URL 도 여기서 나온다 —
호스트를 새 env 로 또 두면 canonical 과 갈릴 수 있다(CLAUDE.md '발행 호스트는 두 곳')."""
- return f"https://{DEFAULT_HOST}"
+ return f"{_scheme(DEFAULT_HOST)}://{DEFAULT_HOST}"
def primary_media(snapshot: dict) -> dict | None:
diff --git a/solution/frontend/.env.example b/solution/frontend/.env.example
index d4c1074..61952c3 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=web4ai.o2osolution.ai
+VITE_PUBLISH_HOST=localhost
# 발행 사이트 렌더러 개발 서버. 에디터 상단 "발행본 사이트 열기" 가 이 주소를 연다.
VITE_SITE_PREVIEW_URL=http://localhost:3000
diff --git a/solution/frontend/src/api/generated/model/placeSearchItem.ts b/solution/frontend/src/api/generated/model/placeSearchItem.ts
index 1aacf27..92eac8d 100644
--- a/solution/frontend/src/api/generated/model/placeSearchItem.ts
+++ b/solution/frontend/src/api/generated/model/placeSearchItem.ts
@@ -21,4 +21,6 @@ export interface PlaceSearchItem {
road_address?: PlaceSearchItemRoadAddress;
category_name?: PlaceSearchItemCategoryName;
category?: PlaceSearchItemCategory;
+ /** @nullable */
+ naver_place_url?: string | null;
}
diff --git a/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx b/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx
index 651aa90..53a38e1 100644
--- a/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx
+++ b/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx
@@ -1,5 +1,5 @@
import {useState} from 'react';
-import {Link2, Loader2, MapPin, Plus} from 'lucide-react';
+import {ExternalLink, Link2, Loader2, MapPin, Search} from 'lucide-react';
import type {LinkChannel as LinkChannelCode} from '@/api';
import {LinkChannel} from '@o2o/shared';
import {Button} from '@/components/ui/button';
@@ -45,15 +45,18 @@ const HOW_TO = [
'네이버 지도(또는 네이버 검색)에서 내 가게를 찾습니다',
'가게 이름 옆 [공유] 를 누릅니다',
'[URL 복사] 를 눌러 주소를 복사합니다',
- '아래 칸에 붙여넣고 [추가] 를 누릅니다',
+ '아래 칸에 붙여넣고 [이 주소로 가져오기] 를 누릅니다',
];
export function ChannelUrlInput({
onAdd,
isBusy,
+ searchQuery,
}: {
onAdd: (url: string, channel: LinkChannelCode, title: string) => Promise;
isBusy?: boolean;
+ /** 이 상호로 네이버 지도를 바로 연다 — 사장님이 새 탭에서 직접 검색어를 치지 않게. */
+ searchQuery?: string;
}) {
const [url, setUrl] = useState('');
const [error, setError] = useState(null);
@@ -88,6 +91,20 @@ export function ChannelUrlInput({
까지 그대로 가져옵니다.
+ {/* ★ 여기에 바로가기가 없으면 사장님은 새 탭을 열고 상호를 다시 타이핑해야 한다.
+ "찾아 보세요" 라고만 하고 갈 곳을 안 주면 그 자리에서 멈춘다. */}
+ {searchQuery?.trim() && (
+
+
+ 네이버 지도에서 '{searchQuery.trim()}' 열기
+
+ )}
+
{HOW_TO.map((step, index) => (
@@ -112,9 +129,11 @@ export function ChannelUrlInput({
placeholder="https://map.naver.com/p/entry/place/1273971279"
disabled={isBusy}
/>
+ {/* ★ '추가' 가 아니다. 이 버튼은 등록에서 끝나지 않고 **그 주소에서 정보를 가져온다.**
+ '추가' 라고 쓰면 사장님은 목록에 한 줄 늘기를 기다리다가 아무 변화가 없어 실패로 읽는다. */}
diff --git a/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx b/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx
index 16b1947..e952c8f 100644
--- a/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx
+++ b/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx
@@ -59,6 +59,15 @@ export function Step2PlaceSearch() {
const search = usePlaceSearch(confirmedIdentity?.placeId ?? null);
const [, goToStep] = useWizardStep();
const [placeUrl, setPlaceUrl] = useState('');
+ /** ★ 후보가 있으면 붙여넣기 칸을 접는다. 펼쳐 두면 "자동으로 못 찾았다"는 신호로 읽힌다 —
+ * 이 시점에는 자동 발견을 시도조차 안 했다(수집 직전에 서버가 한다). 목록에 내 가게가
+ * 없을 때만 여는 탈출구다. */
+ const [urlBoxOpen, setUrlBoxOpen] = useState(false);
+ /** 고른 후보가 들고 온 네이버 플레이스 주소(검색 단계에서 서버가 찾아 준 값). */
+ const naverUrlRef = useRef(null);
+ /** 확정 직후 2단계로 넘어가는 한 번은 초기화를 건너뛴다. */
+ const justConfirmed = useRef(false);
+ const foundCount = search.items.filter((i) => i.naver_place_url).length;
const canSearch = storeName.trim().length > 0 && search.phase !== 'searching';
@@ -81,7 +90,10 @@ export function Step2PlaceSearch() {
const identity = await search.confirmPick(pick, nextIndustry);
setPendingPick(null);
if (!identity) return; // 실패 사유는 search.pickError 가 화면에 남긴다
- confirmIdentity(identity);
+ justConfirmed.current = true;
+ confirmIdentity(
+ naverUrlRef.current ? {...identity, naverPlaceUrl: naverUrlRef.current} : identity,
+ );
advance(identity.placeId);
};
@@ -112,6 +124,9 @@ export function Step2PlaceSearch() {
name: item.name ?? '',
address: item.road_address ?? '',
};
+ // ★ 검색 단계에서 서버가 이미 찾아 둔 플레이스 주소를 그대로 물고 간다.
+ // 이게 있으면 수집 직전에 다시 찾을 필요가 없고, 사장님에게 지도 주소를 묻지도 않는다.
+ naverUrlRef.current = item.naver_place_url ?? null;
const guessed = item.category != null ? (CATEGORY_TO_INDUSTRY[item.category] ?? null) : null;
if (!guessed) {
setPendingPick(pick);
@@ -138,8 +153,9 @@ export function Step2PlaceSearch() {
const pickByUrl = async () => {
const url = placeUrl.trim();
if (!url) return;
- const identity = await search.confirmByUrl(url, industry);
+ const identity = await search.confirmByUrl(url);
if (!identity) return;
+ justConfirmed.current = true;
confirmIdentity(identity);
advance(identity.placeId);
};
@@ -163,6 +179,7 @@ export function Step2PlaceSearch() {
};
// ── 화면 고르기. 위에서부터 먼저 맞는 것 하나만 그린다 ──────────────
+ //
const stage = confirmedIdentity
? 'confirmed'
: search.phase === 'searching'
@@ -173,7 +190,32 @@ export function Step2PlaceSearch() {
? 'picking'
: 'input';
+ /**
+ * 2단계에서 **뒤로 오면 처음(상호 입력)으로 되돌린다.**
+ *
+ * ★ 왜 확정 화면을 안 띄우나: 뒤로가기는 "다시 고르겠다" 는 뜻인데, 확정 화면만 뜨면
+ * 다른 가게로 바꾸러 온 사장님이 목록을 못 찾는다. 후보 목록을 되살리는 방법도 있지만
+ * 공개 검색이 유료 외부 API 라 뒤로 올 때마다 다시 부르게 된다.
+ * ★ 이 화면을 **정상적으로 지나간** 경우(확정 직후 2단계로 넘어감)에는 돌지 않는다 —
+ * `justConfirmed` 가 그 한 번을 막는다. 아니면 확정하자마자 스스로 지워 버린다.
+ */
+ useEffect(() => {
+ if (!confirmedIdentity) return;
+ if (justConfirmed.current) {
+ justConfirmed.current = false;
+ return;
+ }
+ clearIdentity();
+ search.reset();
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- 들어올 때 한 번만 판단한다
+ }, []);
+
const searchQuery = [storeName, location].filter(Boolean).join(' ');
+ /** 뒤로 와서 목록을 다시 볼 때, 이미 고른 후보를 알아보게 한다. */
+ const isPicked = (item: PlaceSearchItem) =>
+ Boolean(confirmedIdentity) &&
+ item.name === confirmedIdentity?.name &&
+ (item.road_address ?? '') === (confirmedIdentity?.address ?? '');
return (
@@ -249,9 +291,11 @@ export function Step2PlaceSearch() {
: 'border border-warning/30 bg-warning/10 text-warning',
)}
>
- {search.items.length > 0
- ? '아래 목록에서 사장님 가게를 골라 주세요.'
- : '이 이름으로는 찾지 못했습니다. 아래에서 네이버 지도 주소로 찾아 주세요.'}
+ {search.items.length === 0
+ ? '이 이름으로는 찾지 못했습니다. 아래에서 네이버 지도 주소로 찾아 주세요.'
+ : foundCount > 0
+ ? `${foundCount}곳은 네이버 플레이스까지 찾았습니다 — 고르시면 영업시간·전화·사진을 그대로 가져옵니다.`
+ : '아래 목록에서 사장님 가게를 골라 주세요.'}
{search.pickError && (
@@ -281,14 +325,33 @@ export function Step2PlaceSearch() {
)}
{/* 고르기 전에 어떤 업종으로 시작하는지 먼저 보여준다 — 고른 뒤에 알면 늦다. */}
+ {/* ★ 플레이스를 찾았는지 **여기서** 보여준다. 데이터만 싣고 표시를 안 하면
+ 사장님은 자동으로 찾았는지 알 길이 없어 지도 주소를 또 찾으러 간다. */}
+ {item.naver_place_url && (
+
+
+ 네이버 플레이스 찾음
+
+ )}