Merge remote-tracking branch 'origin/main'

# Conflicts:
#	solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx
This commit is contained in:
Mina Choi 2026-09-04 16:56:09 +09:00
commit 256de3f5e6
28 changed files with 586 additions and 145 deletions

View File

@ -44,7 +44,8 @@ GOOGLE_CLIENT_ID=
# ── solution/site 발행물 — solution/backend 도 같이 본다 # ── solution/site 발행물 — solution/backend 도 같이 본다
# canonical·og:url·sitemap·IndexNow 가 전부 SITE_PUBLIC_HOST 를 쓴다. # canonical·og:url·sitemap·IndexNow 가 전부 SITE_PUBLIC_HOST 를 쓴다.
SITE_PUBLIC_HOST=w4ai.o2o.kr # 로컬은 비워 둔다(기본값 localhost). 서버에 올릴 때만 실제 도메인을 적는다.
# SITE_PUBLIC_HOST=web4ai.o2osolution.ai
INDEXNOW_KEY= # 비우면 색인 통보를 건너뛴다(발행은 정상) INDEXNOW_KEY= # 비우면 색인 통보를 건너뛴다(발행은 정상)
AZURE_STORAGE_CONNECTION_STRING= # 비우면 로컬 발행만 한다 AZURE_STORAGE_CONNECTION_STRING= # 비우면 로컬 발행만 한다
AZURE_STORAGE_CONTAINER= AZURE_STORAGE_CONTAINER=

View File

@ -35,7 +35,7 @@
`azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 — `azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 —
**렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.** **렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.**
→ `docker compose restart solution-frontend` 후 `python scripts/republish_all.py` → `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 가 전부 `site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부
이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더 이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더
재실행만으로는 안 되고 **백엔드에서 재발행**해 payload 를 다시 만들어야 한다. 재실행만으로는 안 되고 **백엔드에서 재발행**해 payload 를 다시 만들어야 한다.

View File

@ -25,7 +25,9 @@ x-common-env: &common-env
# 수신자(aud)를 대조한다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. # 수신자(aud)를 대조한다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다.
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
# ★ 프론트(VITE_PUBLISH_HOST)와 같은 값이어야 한다. canonical·og:url·sitemap 이 전부 이걸 쓴다. # ★ 프론트(VITE_PUBLISH_HOST)와 같은 값이어야 한다. canonical·og:url·sitemap 이 전부 이걸 쓴다.
SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr} # ★ 기본값은 localhost 다. 운영 도메인을 기본으로 두면 .env 를 안 채운 로컬 빌드가
# 조용히 운영 주소를 번들에 굽는다(실측 2026-09-03: 로컬 링크가 킹서버로 갔다).
SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-localhost}
SITE_PAYLOAD_DIR: /app/out/payloads SITE_PAYLOAD_DIR: /app/out/payloads
SITE_OUTPUT_DIR: /app/out/sites SITE_OUTPUT_DIR: /app/out/sites
# ★ 프리렌더와 같은 값이어야 한다. 어긋나면 색인 통보가 403 이다. # ★ 프리렌더와 같은 값이어야 한다. 어긋나면 색인 통보가 403 이다.
@ -148,7 +150,7 @@ services:
INDEXNOW_KEY: ${INDEXNOW_KEY:-} INDEXNOW_KEY: ${INDEXNOW_KEY:-}
# ★ 발행 호스트를 프론트 .env 에 따로 적지 않는다 — 루트 .env 의 SITE_PUBLIC_HOST 를 # ★ 발행 호스트를 프론트 .env 에 따로 적지 않는다 — 루트 .env 의 SITE_PUBLIC_HOST 를
# 그대로 흘려보낸다. 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다. # 그대로 흘려보낸다. 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr} VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
volumes: volumes:
- ./package.json:/app/package.json - ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json - ./package-lock.json:/app/package-lock.json
@ -186,11 +188,11 @@ services:
environment: environment:
PORT: 3001 PORT: 3001
INDEXNOW_KEY: ${INDEXNOW_KEY:-} INDEXNOW_KEY: ${INDEXNOW_KEY:-}
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr} VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
# ★ 이 둘은 **브라우저가** 부르는 주소다. 컨테이너 안에서 보는 주소가 아니라 # ★ 이 둘은 **브라우저가** 부르는 주소다. 컨테이너 안에서 보는 주소가 아니라
# 화면을 연 사람이 닿을 수 있는 주소여야 한다 — localhost 는 서버에 올리는 순간 틀린다. # 화면을 연 사람이 닿을 수 있는 주소여야 한다 — localhost 는 서버에 올리는 순간 틀린다.
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800} 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_ID: ${AUTO_LOGIN_ID:-}
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-} VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
# 백엔드와 같은 값을 흘려보낸다(루트 .env 가 단일 출처). # 백엔드와 같은 값을 흘려보낸다(루트 .env 가 단일 출처).
@ -231,7 +233,7 @@ services:
VITE_API_BASE_URL: ${ADMIN_API_BASE_URL:-http://localhost:9801} VITE_API_BASE_URL: ${ADMIN_API_BASE_URL:-http://localhost:9801}
# 사장님 앱은 다른 오리진이라 절대 URL 로 연다(admin/frontend/src/lib/solutionUrl.ts). # 사장님 앱은 다른 오리진이라 절대 URL 로 연다(admin/frontend/src/lib/solutionUrl.ts).
# 이걸 안 넘기면 "빌더 열기" 가 서버에서 localhost:3000 을 가리켜 죽은 링크가 된다. # 이걸 안 넘기면 "빌더 열기" 가 서버에서 localhost:3000 을 가리켜 죽은 링크가 된다.
VITE_SOLUTION_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000} VITE_SOLUTION_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
volumes: volumes:
- ./package.json:/app/package.json - ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json - ./package-lock.json:/app/package-lock.json
@ -259,8 +261,8 @@ services:
# ★ VITE_* 는 **번들에 구워진다.** .env 를 고쳐도 재빌드 전엔 안 바뀐다 # ★ VITE_* 는 **번들에 구워진다.** .env 를 고쳐도 재빌드 전엔 안 바뀐다
# → 주소를 바꿨으면 `./deploy.sh solution-site`. # → 주소를 바꿨으면 `./deploy.sh solution-site`.
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800} 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:-localhost}
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_ID: ${AUTO_LOGIN_ID:-}
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-} VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}

View File

@ -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` 를 흘려보낸다 | | 프론트(빌더·`web` 컨테이너) | `VITE_PUBLISH_HOST` | compose 가 루트의 `SITE_PUBLIC_HOST` 를 흘려보낸다 |
★ 프론트 `.env` 에 따로 적지 않는다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다. ★ 프론트 `.env` 에 따로 적지 않는다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.

View File

@ -40,7 +40,7 @@
| 1차 업종 | 숙박, 법무법인, 성형외과 | 숙박, 카페, 음식점, 관광체험 | **반드시 사업 결정 필요.** 법률·의료를 넣으면 데이터 스키마뿐 아니라 규제·승인·감사 체계가 선행되어야 함 | | 1차 업종 | 숙박, 법무법인, 성형외과 | 숙박, 카페, 음식점, 관광체험 | **반드시 사업 결정 필요.** 법률·의료를 넣으면 데이터 스키마뿐 아니라 규제·승인·감사 체계가 선행되어야 함 |
| 입력 방식 | 고객 원본 사이트를 depth 3·최대 200페이지 크롤 | 공식 API, 사용자 확정 URL, 정적 HTML; 플랫폼 우회 수집 금지 | 현재의 출처·동의·robots 원칙 유지. 대규모 원본 사이트 수집은 별도 제품 모드로 분리 | | 입력 방식 | 고객 원본 사이트를 depth 3·최대 200페이지 크롤 | 공식 API, 사용자 확정 URL, 정적 HTML; 플랫폼 우회 수집 금지 | 현재의 출처·동의·robots 원칙 유지. 대규모 원본 사이트 수집은 별도 제품 모드로 분리 |
| 동적 크롤링 | 정적 우선, Playwright 폴백 | 봇 탐지 우회로 변질될 수 있어 HeadlessAdapter 미등록·금지 | 포괄적인 Playwright 폴백은 채택하지 않음. 소유권이 검증된 고객 도메인에만 허용할지 법무·보안 결정 후 제한적으로 검토 | | 동적 크롤링 | 정적 우선, Playwright 폴백 | 봇 탐지 우회로 변질될 수 있어 HeadlessAdapter 미등록·금지 | 포괄적인 Playwright 폴백은 채택하지 않음. 소유권이 검증된 고객 도메인에만 허용할지 법무·보안 결정 후 제한적으로 검토 |
| 발행 도메인 | 고객 도메인 서브패스 권장, 서브도메인 차선 | 기본 `w4ai.o2o.kr/s/<slug>`, custom domain 경로 미완성 | 설계서 방향이 검색 권위 측면에서 더 적합. 고객 도메인 연결·소유권 검증을 우선 과제로 추가 | | 발행 도메인 | 고객 도메인 서브패스 권장, 서브도메인 차선 | 기본 `web4ai.o2osolution.ai/s/<slug>`, custom domain 경로 미완성 | 설계서 방향이 검색 권위 측면에서 더 적합. 고객 도메인 연결·소유권 검증을 우선 과제로 추가 |
| 백엔드 구조 | Site/Brand 엔진, BFF, 엔진별 DB 분리 | 코드 한 벌 + 진입점 둘(:9800 사장님 / :9801 내부), 단일 PostgreSQL | 청중별 분리는 포트로 끝냈다. 엔진별 물리 분리는 트래픽·팀 소유권 근거가 생긴 뒤 수행 | | 백엔드 구조 | Site/Brand 엔진, BFF, 엔진별 DB 분리 | 코드 한 벌 + 진입점 둘(:9800 사장님 / :9801 내부), 단일 PostgreSQL | 청중별 분리는 포트로 끝냈다. 엔진별 물리 분리는 트래픽·팀 소유권 근거가 생긴 뒤 수행 |
| 작업 인프라 | Temporal, Redis, Celery 등 공통 인프라 | PostgreSQL 잡 큐 + lease + dead-letter | 현재 DB 큐 유지. 동일 책임의 인프라를 중복 도입하지 않음. 장기 워크플로 보상·분산 추적 요구가 확인될 때 Temporal 재평가 | | 작업 인프라 | Temporal, Redis, Celery 등 공통 인프라 | PostgreSQL 잡 큐 + lease + dead-letter | 현재 DB 큐 유지. 동일 책임의 인프라를 중복 도입하지 않음. 장기 워크플로 보상·분산 추적 요구가 확인될 때 Temporal 재평가 |
| Fact Graph | 엔티티·predicate·snapshot, append-only | 업종 스키마 기반 key-value fact, 후보/노출/이력 상태 | 현재 모델은 발행 안전성에 적합. Brand 측정 재현성에 필요한 snapshot과 entity 관계만 점진적으로 확장 | | Fact Graph | 엔티티·predicate·snapshot, append-only | 업종 스키마 기반 key-value fact, 후보/노출/이력 상태 | 현재 모델은 발행 안전성에 적합. Brand 측정 재현성에 필요한 snapshot과 entity 관계만 점진적으로 확장 |

View File

@ -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 — 랜딩 · 요금 · 쇼케이스 — 로그인 전 화면이 생겼다 ## 2026-09-03 — 랜딩 · 요금 · 쇼케이스 — 로그인 전 화면이 생겼다
**왜** **왜**

View File

@ -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 등). 컨테이너 34개가 이미 돈다 (negosium · iquote · triple-pick · infinith · gitea · persona 등).
**우리만 쓰는 서버가 아니다** — 포트와 디스크를 남의 것과 나눠 쓴다. **우리만 쓰는 서버가 아니다** — 포트와 디스크를 남의 것과 나눠 쓴다.
## 어디에 두나 — `/home/o2oadmin/data2/o2o-web4ai` ## 어디에 두나 — `/home/o2oadmin/data2/o2o-site-AEO`
레포는 홈이 아니라 **`data2` 밑**에 둔다. 홈이 있는 루트 디스크와 다른 물리 디스크다. 레포는 홈이 아니라 **`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/<레포명>` 이다. `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 를 라우팅 중). ★ **`: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 ```bash
cd ~/data2/o2o-web4ai cd ~/data2/o2o-site-AEO
./deploy.sh # 전체 (git pull → build → up -d) ./deploy.sh # 전체 (git pull → build → up -d)
./deploy.sh api # 그 서비스만 ./deploy.sh api # 그 서비스만
./log.sh # 1=전체, 2번부터 개별 컨테이너 ./log.sh # 1=전체, 2번부터 개별 컨테이너
@ -107,8 +112,8 @@ cd ~/data2/o2o-web4ai
★ `deploy.sh api` 는 worker·api-admin 도 함께 갈아끼운다. 셋이 이미지 한 벌을 나눠 쓰기 때문이다 — ★ `deploy.sh api` 는 worker·api-admin 도 함께 갈아끼운다. 셋이 이미지 한 벌을 나눠 쓰기 때문이다 —
안 그러면 옛 코드로 도는 컨테이너가 남는데 셋 다 "살아 있음" 이라 눈으로는 구분이 안 된다. 안 그러면 옛 코드로 도는 컨테이너가 남는데 셋 다 "살아 있음" 이라 눈으로는 구분이 안 된다.
★ 서버에 gitea 자격증명이 없어 `git pull` 이 실패한다. 밖에서 밀어넣고 `./deploy.sh --no-pull` ★ 새 클론(`o2o-site-AEO`)은 gitea 자격증명이 통해서 `./deploy.sh` 를 서버에서 그대로 쓴다.
로 돌리거나, gitea 에 배포키를 등록해 원격을 SSH 로 바꾼다. 옛 `o2o-web4ai` 는 안 됐다 — fetch 가 죽으면 deploy.sh 가 리셋을 건너뛰고 디스크 코드로 간다.
## DB ## DB
@ -117,13 +122,22 @@ cd ~/data2/o2o-web4ai
붙여 두었으므로 **컴포즈를 고치지 않고 그대로 닿는다.** DB 는 만들어야 한다 — 붙여 두었으므로 **컴포즈를 고치지 않고 그대로 닿는다.** DB 는 만들어야 한다 —
`postgres-init/init-data/init.sql` 한 벌이 스키마 전부다. `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 └ 앞단 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` 은 **사설 `59.14.81.3` 은 사내망 입구다(`gitea.o2o.kr` 과 같은 IP). 킹서버의 `172.30.1.36` 은 **사설
IP라 DNS 에 못 적는다** — 앞단 vhost 와 인증서는 이 레포 밖이고 인프라 담당이 잡는다. IP라 DNS 에 못 적는다** — 앞단 vhost 와 인증서는 이 레포 밖이고 인프라 담당이 잡는다.

View File

@ -444,3 +444,7 @@ ALTER TABLE company.users ALTER COLUMN password DROP NOT NULL;
-- 생기므로, 인덱스를 먼저 만들면 "column provider_uid does not exist" 로 스크립트가 통째로 멈춘다. -- 생기므로, 인덱스를 먼저 만들면 "column provider_uid does not exist" 로 스크립트가 통째로 멈춘다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON company.users (provider, provider_uid) 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; 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;

View File

@ -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()

View File

@ -223,6 +223,9 @@ class PlaceSearchItem(WebPacketProtocol):
# 추정 업종. ★ None 이면 못 정한 것이다 — 화면이 사장님에게 직접 고르게 한다. # 추정 업종. ★ None 이면 못 정한 것이다 — 화면이 사장님에게 직접 고르게 한다.
# 값이 있어도 확정이 아니다. 화면은 언제나 바꿀 수 있게 둔다(경계 업종이 실제로 있다). # 값이 있어도 확정이 아니다. 화면은 언제나 바꿀 수 있게 둔다(경계 업종이 실제로 있다).
category: Optional[PlaceCategory] = None category: Optional[PlaceCategory] = None
# 자동으로 찾은 네이버 플레이스 주소. ★ 공개 페이지에서 읽은 값이라 우리 DB 것이 아니다.
# 못 찾으면 None — 화면이 그때만 사장님에게 지도 주소를 묻는다.
naver_place_url: Optional[str] = None
class Res_PlaceSearch(Res_WebPacketProtocol): class Res_PlaceSearch(Res_WebPacketProtocol):

View File

@ -1,7 +1,7 @@
"""발행본이 **인터넷에서** 검색엔진에 읽힐 준비가 됐는지 확인한다. """발행본이 **인터넷에서** 검색엔진에 읽힐 준비가 됐는지 확인한다.
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://w4ai.o2o.kr python scripts/check_search_ready.py https://web4ai.o2osolution.ai
python scripts/check_search_ready.py --slug butter (특정 사이트만) 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: def main() -> None:
parser = argparse.ArgumentParser() 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="이 사이트만 확인(기본: 사이트맵의 첫 사이트)") parser.add_argument("--slug", help="이 사이트만 확인(기본: 사이트맵의 첫 사이트)")
args = parser.parse_args() args = parser.parse_args()
origin = args.origin.rstrip("/") origin = args.origin.rstrip("/")

View File

@ -52,6 +52,20 @@ def _normalize(text: str) -> str:
return re.sub(r"[\s,·.\-_'\"()&]", "", (text or "")).lower() 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]: async def _fetch_search_html(query: str) -> Optional[str]:
"""통합검색 결과 페이지 원문. 실패는 None — 호출측이 조용히 폴백한다.""" """통합검색 결과 페이지 원문. 실패는 None — 호출측이 조용히 폴백한다."""
try: 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) html = await _fetch_search_html(query)
if not html: if not html:
return None return None

View File

@ -50,6 +50,12 @@ from services.external.naver import region_key
from services.job_service import enqueue_job from services.job_service import enqueue_job
# 겨냥 조회를 몇 건까지 할지. 후보 전부(5건)를 부르면 통합검색이 429 를 준다.
_TARGETED_LOOKUP_LIMIT = 2
# 공개 검색 캐시 수명. 가게 정보가 이 안에 바뀔 일은 없다.
_PUBLIC_SEARCH_CACHE_SEC = 600
class PlaceService: class PlaceService:
"""사업장 등록·조회·동일 업소 검증. """사업장 등록·조회·동일 업소 검증.
@ -605,9 +611,10 @@ class PlaceService:
★ DB 를 읽지도 쓰지도 않는다. 나가는 값은 외부 장소 DB 가 공개적으로 주는 것뿐이다. ★ DB 를 읽지도 쓰지도 않는다. 나가는 값은 외부 장소 DB 가 공개적으로 주는 것뿐이다.
""" """
from common.enums import ExternalPlaceSource 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 kakao as kakao_client
from services.external import naver as naver_client from services.external import naver as naver_client
from services.external import naver_place_lookup
from services.place_category import guess_category from services.place_category import guess_category
res = Res_PlaceSearch() res = Res_PlaceSearch()
@ -616,6 +623,14 @@ class PlaceService:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
return res 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 이면 새로고침만으로 요금이 나간다. # ★ 인증이 없는데 유료 외부 API 를 부른다 — 방어가 0 이면 새로고침만으로 요금이 나간다.
# 프로세스 메모리 기반이라 완전하지 않다(common/utils/rate_limit.py 주석). # 프로세스 메모리 기반이라 완전하지 않다(common/utils/rate_limit.py 주석).
if not rate_limit.allow(f"place-search:{client_ip}", _PUBLIC_SEARCH_PER_MIN, 60.0): 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) res.result.SetResult(ErrorType.LOCAL_FETCH_FAILED)
return res 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 = [ res.items = [
PlaceSearchItem( PlaceSearchItem(
name=row.name, name=row.name,
@ -651,11 +683,19 @@ class PlaceService:
category_name=row.category_name, category_name=row.category_name,
# 네이버는 그룹코드를 주지 않는다 — 그때는 분류 문자열만으로 추정한다. # 네이버는 그룹코드를 주지 않는다 — 그때는 분류 문자열만으로 추정한다.
category=guess_category(row.category_name, getattr(row, "category_group_code", None)), 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 for row in rows
] ]
if not res.items: if not res.items:
# ★ 빈 결과는 캐시하지 않는다. 네이버가 잠깐 막아서 0건이 나온 것을 굳히면
# 사장님은 TTL 이 끝날 때까지 아무것도 못 한다.
res.result.SetResult(ErrorType.PLACE_VERIFY_NO_CANDIDATE) 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 return res
async def find_candidates(self, user_info: UserInfo, place_id: str, query: str | None = None) -> Res_VerifyCandidates: 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 직접 입력으로 이어진다 except Exception as ex: # noqa: BLE001 — 지도 URL 직접 입력으로 이어진다
LOG.w(f"[verify] 네이버 플레이스 자동 발견 실패(후보는 유지): {type(ex).__name__}: {ex}") LOG.w(f"[verify] 네이버 플레이스 자동 발견 실패(후보는 유지): {type(ex).__name__}: {ex}")
naver_ids = {} 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 = [ res.candidates = [
PlaceCandidate( PlaceCandidate(
external_place_id=getattr(c, "kakao_place_id", None) or getattr(c, "naver_place_id", None), external_place_id=getattr(c, "kakao_place_id", None) or getattr(c, "naver_place_id", None),

View File

@ -15,6 +15,7 @@
(channel 링크만 스냅샷에 없어서 호출측이 읽어 넘긴다.) (channel 링크만 스냅샷에 없어서 호출측이 읽어 넘긴다.)
""" """
import json import json
import re
import os import os
import unicodedata import unicodedata
from datetime import datetime, timezone from datetime import datetime, timezone
@ -45,7 +46,13 @@ DEFAULT_PAYLOAD_DIR = "/app/out/payloads"
# 검색엔진에는 열리지도 않는 주소가 등록된다(조용히 틀린다 — 아무도 눈치채지 못한다). # 검색엔진에는 열리지도 않는 주소가 등록된다(조용히 틀린다 — 아무도 눈치채지 못한다).
# 프론트도 같은 이유로 VITE_PUBLISH_HOST 를 쓴다. 두 값은 **같아야 한다**. # 프론트도 같은 이유로 VITE_PUBLISH_HOST 를 쓴다. 두 값은 **같아야 한다**.
SITE_HOST_ENV = "SITE_PUBLIC_HOST" SITE_HOST_ENV = "SITE_PUBLIC_HOST"
DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "w4ai.o2o.kr" # 기본값은 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 = { _CHANNEL_TITLE = {
@ -550,7 +557,7 @@ def _publish_target(site, place_id: str, name: str) -> dict:
name_slug = slugify(name) name_slug = slugify(name)
slug = f"{name_slug}-{place_id[:8]}" if name_slug else f"place-{place_id[:8]}" 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: def publish_slug(place, site) -> str:
@ -565,7 +572,7 @@ def publish_slug(place, site) -> str:
def publish_origin() -> str: def publish_origin() -> str:
"""발행본이 사는 오리진. 썸네일 URL 도 여기서 나온다 — """발행본이 사는 오리진. 썸네일 URL 도 여기서 나온다 —
호스트를 새 env 로 또 두면 canonical 과 갈릴 수 있다(CLAUDE.md '발행 호스트는 두 곳').""" 호스트를 새 env 로 또 두면 canonical 과 갈릴 수 있다(CLAUDE.md '발행 호스트는 두 곳')."""
return f"https://{DEFAULT_HOST}" return f"{_scheme(DEFAULT_HOST)}://{DEFAULT_HOST}"
def primary_media(snapshot: dict) -> dict | None: def primary_media(snapshot: dict) -> dict | None:

View File

@ -5,7 +5,7 @@ VITE_API_BASE_URL=http://localhost:9800
# 커스텀 도메인이 붙기 전까지 <slug>.<이 값> 형태로 나간다. # 커스텀 도메인이 붙기 전까지 <slug>.<이 값> 형태로 나간다.
# ★ 로컬에서 compose 없이 띄울 때만 쓴다. compose 로 띄우면 루트 .env 의 SITE_PUBLIC_HOST 가 # ★ 로컬에서 compose 없이 띄울 때만 쓴다. compose 로 띄우면 루트 .env 의 SITE_PUBLIC_HOST 가
# 주입돼 이 값을 덮는다 — 발행 호스트의 단일 출처는 루트 .env 다. # 주입돼 이 값을 덮는다 — 발행 호스트의 단일 출처는 루트 .env 다.
VITE_PUBLISH_HOST=w4ai.o2o.kr VITE_PUBLISH_HOST=localhost
# 발행 사이트 렌더러 개발 서버. 에디터 상단 "발행본 사이트 열기" 가 이 주소를 연다. # 발행 사이트 렌더러 개발 서버. 에디터 상단 "발행본 사이트 열기" 가 이 주소를 연다.
VITE_SITE_PREVIEW_URL=http://localhost:3000 VITE_SITE_PREVIEW_URL=http://localhost:3000

View File

@ -0,0 +1 @@
google-site-verification: google60b514c02fd6af4e.html

View File

@ -0,0 +1 @@
naver-site-verification: naver71762ee96e2e126623dc1da07ecae493.html

View File

@ -21,4 +21,6 @@ export interface PlaceSearchItem {
road_address?: PlaceSearchItemRoadAddress; road_address?: PlaceSearchItemRoadAddress;
category_name?: PlaceSearchItemCategoryName; category_name?: PlaceSearchItemCategoryName;
category?: PlaceSearchItemCategory; category?: PlaceSearchItemCategory;
/** @nullable */
naver_place_url?: string | null;
} }

View File

@ -1,5 +1,5 @@
import {useState} from 'react'; 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 type {LinkChannel as LinkChannelCode} from '@/api';
import {LinkChannel} from '@o2o/shared'; import {LinkChannel} from '@o2o/shared';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
@ -45,15 +45,18 @@ const HOW_TO = [
'네이버 지도(또는 네이버 검색)에서 내 가게를 찾습니다', '네이버 지도(또는 네이버 검색)에서 내 가게를 찾습니다',
'가게 이름 옆 [공유] 를 누릅니다', '가게 이름 옆 [공유] 를 누릅니다',
'[URL 복사] 를 눌러 주소를 복사합니다', '[URL 복사] 를 눌러 주소를 복사합니다',
'아래 칸에 붙여넣고 [추가] 를 누릅니다', '아래 칸에 붙여넣고 [이 주소로 가져오기] 를 누릅니다',
]; ];
export function ChannelUrlInput({ export function ChannelUrlInput({
onAdd, onAdd,
isBusy, isBusy,
searchQuery,
}: { }: {
onAdd: (url: string, channel: LinkChannelCode, title: string) => Promise<void>; onAdd: (url: string, channel: LinkChannelCode, title: string) => Promise<void>;
isBusy?: boolean; isBusy?: boolean;
/** 이 상호로 네이버 지도를 바로 연다 — 사장님이 새 탭에서 직접 검색어를 치지 않게. */
searchQuery?: string;
}) { }) {
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -88,6 +91,20 @@ export function ChannelUrlInput({
까지 그대로 가져옵니다. 까지 그대로 가져옵니다.
</p> </p>
{/* ★ 여기에 바로가기가 없으면 사장님은 새 탭을 열고 상호를 다시 타이핑해야 한다.
"찾아 보세요" 라고만 하고 갈 곳을 안 주면 그 자리에서 멈춘다. */}
{searchQuery?.trim() && (
<a
href={`https://map.naver.com/p/search/${encodeURIComponent(searchQuery.trim())}`}
target="_blank"
rel="noopener noreferrer"
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-primary/40 bg-background px-3 py-2.5 text-xs font-semibold transition-colors hover:bg-accent"
>
<ExternalLink className="size-3.5" />
<span>네이버 지도에서 '{searchQuery.trim()}' 열기</span>
</a>
)}
<ol className="mt-3 space-y-1.5"> <ol className="mt-3 space-y-1.5">
{HOW_TO.map((step, index) => ( {HOW_TO.map((step, index) => (
<li key={step} className="flex items-start gap-2 text-[11px] leading-relaxed"> <li key={step} className="flex items-start gap-2 text-[11px] leading-relaxed">
@ -112,9 +129,11 @@ export function ChannelUrlInput({
placeholder="https://map.naver.com/p/entry/place/1273971279" placeholder="https://map.naver.com/p/entry/place/1273971279"
disabled={isBusy} disabled={isBusy}
/> />
{/* ★ '추가' 가 아니다. 이 버튼은 등록에서 끝나지 않고 **그 주소에서 정보를 가져온다.**
'추가' 라고 쓰면 사장님은 목록에 한 줄 늘기를 기다리다가 아무 변화가 없어 실패로 읽는다. */}
<Button size="sm" variant="primary" onClick={() => void submit()} disabled={isBusy || !url.trim()}> <Button size="sm" variant="primary" onClick={() => void submit()} disabled={isBusy || !url.trim()}>
{isBusy ? <Loader2 className="animate-spin" /> : <Plus />} {isBusy ? <Loader2 className="animate-spin" /> : <Search />}
<span>추가</span> <span>{isBusy ? '가져오는 중' : '이 주소로 가져오기'}</span>
</Button> </Button>
</div> </div>

View File

@ -65,6 +65,15 @@ export function Step2PlaceSearch() {
const search = usePlaceSearch(confirmedIdentity?.placeId ?? null); const search = usePlaceSearch(confirmedIdentity?.placeId ?? null);
const [step, goToStep] = useWizardStep(); const [step, goToStep] = useWizardStep();
const [placeUrl, setPlaceUrl] = useState(''); const [placeUrl, setPlaceUrl] = useState('');
/** ★ 후보가 있으면 붙여넣기 칸을 접는다. 펼쳐 두면 "자동으로 못 찾았다"는 신호로 읽힌다 —
* 이 시점에는 자동 발견을 시도조차 안 했다(수집 직전에 서버가 한다). 목록에 내 가게가
* 없을 때만 여는 탈출구다. */
const [urlBoxOpen, setUrlBoxOpen] = useState(false);
/** 고른 후보가 들고 온 네이버 플레이스 주소(검색 단계에서 서버가 찾아 준 값). */
const naverUrlRef = useRef<string | null>(null);
/** 확정 직후 2단계로 넘어가는 한 번은 초기화를 건너뛴다. */
const justConfirmed = useRef(false);
const foundCount = search.items.filter((i) => i.naver_place_url).length;
// ★ usePlaceSearch.searchPublic 과 같은 조건이다 — 여기서 더 느슨하면 눌러도 검색이 안 나가고 // ★ usePlaceSearch.searchPublic 과 같은 조건이다 — 여기서 더 느슨하면 눌러도 검색이 안 나가고
// 화면만 후보 단계로 넘어간다(빈 목록). // 화면만 후보 단계로 넘어간다(빈 목록).
@ -90,7 +99,10 @@ export function Step2PlaceSearch() {
const identity = await search.confirmPick(pick, nextIndustry); const identity = await search.confirmPick(pick, nextIndustry);
setPendingPick(null); setPendingPick(null);
if (!identity) return; // 실패 사유는 search.pickError 가 화면에 남긴다 if (!identity) return; // 실패 사유는 search.pickError 가 화면에 남긴다
confirmIdentity(identity); justConfirmed.current = true;
confirmIdentity(
naverUrlRef.current ? {...identity, naverPlaceUrl: naverUrlRef.current} : identity,
);
advance(identity.placeId); advance(identity.placeId);
}; };
@ -147,6 +159,9 @@ export function Step2PlaceSearch() {
name: item.name ?? '', name: item.name ?? '',
address: item.road_address ?? '', address: item.road_address ?? '',
}; };
// ★ 검색 단계에서 서버가 이미 찾아 둔 플레이스 주소를 그대로 물고 간다.
// 이게 있으면 수집 직전에 다시 찾을 필요가 없고, 사장님에게 지도 주소를 묻지도 않는다.
naverUrlRef.current = item.naver_place_url ?? null;
const guessed = item.category != null ? (CATEGORY_TO_INDUSTRY[item.category] ?? null) : null; const guessed = item.category != null ? (CATEGORY_TO_INDUSTRY[item.category] ?? null) : null;
if (!guessed) { if (!guessed) {
setPendingPick(pick); setPendingPick(pick);
@ -180,8 +195,9 @@ export function Step2PlaceSearch() {
const pickByUrl = async () => { const pickByUrl = async () => {
const url = placeUrl.trim(); const url = placeUrl.trim();
if (!url) return; if (!url) return;
const identity = await search.confirmByUrl(url, industry); const identity = await search.confirmByUrl(url);
if (!identity) return; if (!identity) return;
justConfirmed.current = true;
confirmIdentity(identity); confirmIdentity(identity);
advance(identity.placeId); advance(identity.placeId);
}; };
@ -220,7 +236,32 @@ export function Step2PlaceSearch() {
? 'unavailable' ? 'unavailable'
: 'picking'; : 'picking';
/**
* 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 searchQuery = [storeName, location].filter(Boolean).join(' ');
/** 뒤로 와서 목록을 다시 볼 때, 이미 고른 후보를 알아보게 한다. */
const isPicked = (item: PlaceSearchItem) =>
Boolean(confirmedIdentity) &&
item.name === confirmedIdentity?.name &&
(item.road_address ?? '') === (confirmedIdentity?.address ?? '');
return ( return (
<div className="flex flex-1 flex-col justify-between bg-muted/30"> <div className="flex flex-1 flex-col justify-between bg-muted/30">
@ -303,9 +344,11 @@ export function Step2PlaceSearch() {
: 'border border-warning/30 bg-warning/10 text-warning', : 'border border-warning/30 bg-warning/10 text-warning',
)} )}
> >
{search.items.length > 0 {search.items.length === 0
? '아래 목록에서 사장님 가게를 골라 주세요.' ? '이 이름으로는 찾지 못했습니다. 아래에서 네이버 지도 주소로 찾아 주세요.'
: '이 이름으로는 찾지 못했습니다. 아래에서 네이버 지도 주소로 찾아 주세요.'} : foundCount > 0
? `${foundCount}곳은 네이버 플레이스까지 찾았습니다 — 고르시면 영업시간·전화·사진을 그대로 가져옵니다.`
: '아래 목록에서 사장님 가게를 골라 주세요.'}
</p> </p>
{search.pickError && ( {search.pickError && (
@ -335,14 +378,33 @@ export function Step2PlaceSearch() {
)} )}
{/* 고르기 전에 어떤 업종으로 시작하는지 먼저 보여준다 — 고른 뒤에 알면 늦다. */} {/* 고르기 전에 어떤 업종으로 시작하는지 먼저 보여준다 — 고른 뒤에 알면 늦다. */}
<IndustryBadge category={item.category} /> <IndustryBadge category={item.category} />
{/* ★ 플레이스를 찾았는지 **여기서** 보여준다. 데이터만 싣고 표시를 안 하면
사장님은 자동으로 찾았는지 알 길이 없어 지도 주소를 또 찾으러 간다. */}
{item.naver_place_url && (
<Badge variant="success" className="gap-1 text-[10px]">
<Check className="size-3" />
네이버 플레이스 찾음
</Badge>
)}
</div> </div>
<p className="mt-1.5 flex items-start gap-1 text-xs text-muted-foreground"> <p className="mt-1.5 flex items-start gap-1 text-xs text-muted-foreground">
<MapPin className="mt-px size-3 shrink-0" /> <MapPin className="mt-px size-3 shrink-0" />
<span>{item.road_address ?? '주소 없음'}</span> <span>{item.road_address ?? '주소 없음'}</span>
</p> </p>
</div> </div>
<span className="shrink-0 rounded-lg bg-primary px-3 py-1.5 text-[11px] font-bold text-primary-foreground"> <span
{search.isConfirming ? '확정 중...' : '이 가게예요'} className={cn(
'shrink-0 rounded-lg px-3 py-1.5 text-[11px] font-bold',
isPicked(item)
? 'bg-success text-success-foreground'
: 'bg-primary text-primary-foreground',
)}
>
{search.isConfirming
? '확정 중...'
: isPicked(item)
? '고른 가게'
: '이 가게예요'}
</span> </span>
</div> </div>
</button> </button>
@ -357,14 +419,24 @@ export function Step2PlaceSearch() {
onChange={changeIndustry} onChange={changeIndustry}
/> />
<PlaceUrlBox {search.items.length > 0 && !urlBoxOpen && !search.pickError ? (
value={placeUrl} <button
onChange={setPlaceUrl} type="button"
onSubmit={() => void pickByUrl()} onClick={() => setUrlBoxOpen(true)}
isBusy={search.isConfirming} className="w-full cursor-pointer rounded-xl border border-dashed border-border px-3 py-2.5 text-[11px] text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground"
compact={search.items.length > 0 && !search.pickError} >
searchQuery={searchQuery} 목록에 내 가게가 없나요? — 네이버 지도 주소로 찾기
/> </button>
) : (
<PlaceUrlBox
value={placeUrl}
onChange={setPlaceUrl}
onSubmit={() => void pickByUrl()}
isBusy={search.isConfirming}
compact={search.items.length > 0 && !search.pickError}
searchQuery={searchQuery}
/>
)}
<Button variant="ghost" size="sm" className="w-full" onClick={backToInput}> <Button variant="ghost" size="sm" className="w-full" onClick={backToInput}>
<Search /> <Search />

View File

@ -6,8 +6,13 @@ import {Input} from '@/components/ui/input';
import {Switch} from '@/components/ui/switch'; import {Switch} from '@/components/ui/switch';
import {INDUSTRY_CONFIGS} from '@/data/industryData'; import {INDUSTRY_CONFIGS} from '@/data/industryData';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import type {LinkChannel as LinkChannelCode} from '@/api';
import {getAccessToken} from '@/api';
import {SignInForm} from '@/features/auth/SignInForm';
import {useAuthStore} from '@/stores/auth';
import {useBuilderStore, useUnverifiedFields} from '@/stores/builder'; import {useBuilderStore, useUnverifiedFields} from '@/stores/builder';
import {ChannelConfirmPanel} from './ChannelConfirmPanel'; import {ChannelConfirmPanel} from './ChannelConfirmPanel';
import {ensureServerPlace} from './ensureServerPlace';
import {ChannelUrlInput} from './ChannelUrlInput'; import {ChannelUrlInput} from './ChannelUrlInput';
import {useCollectFlow} from './useCollectFlow'; import {useCollectFlow} from './useCollectFlow';
import {STEP_NUMBER, useWizardStep} from './wizardUrl'; import {STEP_NUMBER, useWizardStep} from './wizardUrl';
@ -40,9 +45,67 @@ export function Step3DataReview() {
const photos = useBuilderStore((s) => s.photos); const photos = useBuilderStore((s) => s.photos);
const [, goToStep] = useWizardStep(); const [, goToStep] = useWizardStep();
const confirmIdentity = useBuilderStore((s) => s.confirmIdentity);
const user = useAuthStore((s) => s.user);
const unverified = useUnverifiedFields(); const unverified = useUnverifiedFields();
const placeId = confirmedIdentity?.placeId ?? null; const placeId = confirmedIdentity?.placeId ?? null;
const collect = useCollectFlow(placeId); const collect = useCollectFlow(placeId);
/** 로그인 관문을 이 화면에서 연다 — 위저드에서 서버를 처음 부르는 지점이 여기다. */
const [signingIn, setSigningIn] = useState(false);
const [isUploading, setIsUploading] = useState(false);
/** 찾아 둔 주소가 있어도 사장님이 직접 넣겠다고 하면 펼친다. */
const [urlBoxOpen, setUrlBoxOpen] = useState(false);
/**
* 수집 시작 — **위저드에서 서버를 처음 부르는 지점.**
*
* ★ 1단계의 확정은 화면에만 남는다(usePlaceSearch). 그래서 여기서 세 가지를 순서대로 한다:
* 로그인 → 사업장 생성·검증(ensureServerPlace) → 수집. 로그인을 앞 단계로 올리면
* 아무것도 못 본 사장님에게 가입을 요구하게 된다(docs/PRODUCT.md "로그인 최소").
*/
/** 로그인 모달을 띄우기 직전의 신원. 로그인이 끝나면 그대로 이어 돌린다. */
const pendingAfterSignIn = useRef<typeof confirmedIdentity>(null);
const beginCollect = async (identity = confirmedIdentity) => {
if (!identity) return;
if (!user && !getAccessToken()) {
// ★ 로그인 뒤에 이어서 돌 값을 들고 있는다. 안 들고 있으면 로그인만 하고 제자리로
// 돌아와, 사장님이 같은 버튼을 두 번 눌러야 한다(실측 2026-09-03).
pendingAfterSignIn.current = identity;
setSigningIn(true);
return;
}
if (!identity.placeId) {
setIsUploading(true);
const uploaded = await ensureServerPlace(identity, industry);
setIsUploading(false);
if (!uploaded?.placeId) return;
confirmIdentity(uploaded);
return; // placeId 가 스토어에 박히면 아래 자동 수집 effect 가 이어받는다
}
void collect.crawl();
};
/**
* 사장님이 붙여넣은 주소를 받는다.
*
* ★ 서버에 사업장이 없으면 `collect.addLink` 는 **조용히 아무것도 안 한다**(placeId 가
* 없어서 첫 줄에서 return). 그러면 입력칸만 비워져 사장님은 실패로 읽는다(실측).
* 그래서 그 경우엔 신원에 주소를 싣고 같은 흐름(로그인 → 생성 → 검증 → 수집)으로 보낸다.
*/
const addUrl = async (url: string, channel: LinkChannelCode, title: string) => {
if (confirmedIdentity?.placeId) {
await collect.addLink(url, channel, title);
return;
}
if (!confirmedIdentity) return;
const next = {...confirmedIdentity, naverPlaceUrl: url};
confirmIdentity(next);
await beginCollect(next);
};
const collectPhase = collect.phase; const collectPhase = collect.phase;
const startAutomaticCollect = collect.crawl; const startAutomaticCollect = collect.crawl;
const autoCollectStarted = useRef(false); const autoCollectStarted = useRef(false);
@ -75,6 +138,15 @@ export function Step3DataReview() {
const config = INDUSTRY_CONFIGS[industry]; const config = INDUSTRY_CONFIGS[industry];
const canCollect = confirmedIdentity?.origin === 'external'; const canCollect = confirmedIdentity?.origin === 'external';
/** ★ 버튼을 언제 열어 두나.
* `confirmedCount` 는 **서버에 사업장이 있어야** 나오는 값이다. 그것만 보면 로그인 전에는
* 버튼이 영원히 잠겨, 자동 발견이 돌 기회조차 없다(실측 2026-09-03: 후보를 골라도
* "주소를 먼저 붙여넣어 주세요" 로 잠긴 채 멈춘다).
* 아직 서버에 안 올렸으면(=placeId 없음) 자동 발견을 시도해 볼 여지가 있으므로 열어 둔다.
* 올렸는데도 채널이 0이면 그때는 자동 발견이 실패한 것이라 붙여넣기를 요구한다. */
const pendingUrl = confirmedIdentity?.naverPlaceUrl;
const autoLookupPending = canCollect && !placeId;
const hasSource = collect.confirmedCount > 0 || Boolean(pendingUrl) || autoLookupPending;
/** 아직 실제 수집이 붙지 않은 줄들 — 업종 예시다. 그렇게 부른다. */ /** 아직 실제 수집이 붙지 않은 줄들 — 업종 예시다. 그렇게 부른다. */
const isSample = !confirmedIdentity || confirmedIdentity.origin === 'owner'; const isSample = !confirmedIdentity || confirmedIdentity.origin === 'owner';
@ -89,13 +161,52 @@ export function Step3DataReview() {
? 'review' ? 'review'
: 'ready'; : 'ready';
// ★ /login 으로 튕기지 않는다. 여기까지 쌓은 위저드 상태는 주소창에 없어서 돌아올 길이 없다.
if (signingIn) {
return (
<div className="flex flex-1 items-center justify-center bg-muted/30 px-4 py-10">
<SignInForm
submitLabel="로그인하고 정보 가져오기"
onSignedIn={() => {
setSigningIn(false);
const resume = pendingAfterSignIn.current;
pendingAfterSignIn.current = null;
if (resume) void beginCollect(resume);
}}
header={
<div className="space-y-1 text-center">
<h1 className="text-sm font-bold">정보를 가져오려면 로그인해 주세요</h1>
<p className="text-xs leading-relaxed text-muted-foreground">
고르신 가게는 그대로 있습니다. 수집한 정보를 사장님 계정에 담아 두기 위해
여기서 한 번만 받습니다.
</p>
</div>
}
footer={
<button
type="button"
onClick={() => setSigningIn(false)}
className="w-full text-center text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
나중에 하기
</button>
}
/>
</div>
);
}
return ( return (
<div className="flex flex-1 flex-col justify-between bg-muted/30"> <div className="flex flex-1 flex-col justify-between bg-muted/30">
<WizardStage <WizardStage
current={STEP_NUMBER.collect} current={STEP_NUMBER.collect}
wide={stage === 'review' || stage === 'channels'} wide={stage === 'review' || stage === 'channels'}
title={STAGE_TITLE[stage]} title={stage === 'ready' && pendingUrl ? '네이버 플레이스를 찾았습니다' : STAGE_TITLE[stage]}
description={STAGE_DESCRIPTION[stage]} description={
stage === 'ready' && pendingUrl
? '이 가게에서 영업시간·전화·편의시설과 사진을 그대로 가져옵니다.'
: STAGE_DESCRIPTION[stage]
}
> >
{stage === 'ready' && ( {stage === 'ready' && (
<div className="space-y-3"> <div className="space-y-3">
@ -113,22 +224,52 @@ export function Step3DataReview() {
</p> </p>
)} )}
{/* ★ 1순위 경로. URL 만 있으면 100% 되므로 화면에서도 맨 위에 둔다. */} {/* ★ 이미 찾아 둔 주소가 있으면 붙여넣기 안내를 접는다. 펼쳐 두면 사장님은
{canCollect && <ChannelUrlInput onAdd={collect.addLink} isBusy={collect.isAdding} />} "또 못 찾았구나" 로 읽고 지도를 열러 간다 — 찾아 놓고 그러는 건 최악이다. */}
{canCollect && pendingUrl && !urlBoxOpen ? (
<div className="rounded-2xl border border-success/40 bg-success/5 p-4">
<p className="flex items-center gap-1.5 text-xs font-bold">
<Check className="size-3.5 text-success" />
네이버 플레이스를 찾았습니다
</p>
<p className="mt-1 truncate text-[11px] text-muted-foreground">{pendingUrl}</p>
<button
type="button"
onClick={() => setUrlBoxOpen(true)}
className="mt-2 cursor-pointer text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
이 가게가 아닌가요? — 주소 직접 넣기
</button>
</div>
) : (
canCollect && (
<ChannelUrlInput
onAdd={addUrl}
isBusy={collect.isAdding || isUploading}
searchQuery={[confirmedIdentity?.name, confirmedIdentity?.address]
.filter(Boolean)
.join(' ')}
/>
)
)}
{canCollect && ( {canCollect && (
<Button <Button
variant="primary" variant="primary"
size="lg" size="lg"
className="w-full" className="w-full"
onClick={collect.crawl} onClick={() => void beginCollect()}
disabled={collect.confirmedCount === 0} disabled={isUploading || !hasSource}
> >
<Search /> <Search />
<span> <span>
{collect.confirmedCount === 0 {!hasSource
? '주소를 먼저 붙여넣어 주세요' ? '주소를 먼저 붙여넣어 주세요'
: `등록한 ${collect.confirmedCount}개 주소에서 정보 가져오기`} : collect.confirmedCount > 0
? `등록한 ${collect.confirmedCount}개 주소에서 정보 가져오기`
: isUploading
? '내 가게를 찾는 중입니다...'
: '내 가게 정보 자동으로 가져오기'}
</span> </span>
</Button> </Button>
)} )}
@ -217,20 +358,26 @@ export function Step3DataReview() {
/> />
</div> </div>
<ChannelUrlInput onAdd={collect.addLink} isBusy={collect.isAdding} /> <ChannelUrlInput
onAdd={collect.addLink}
isBusy={collect.isAdding}
searchQuery={[confirmedIdentity?.name, confirmedIdentity?.address].filter(Boolean).join(' ')}
/>
<Button <Button
variant="primary" variant="primary"
size="lg" size="lg"
className="w-full" className="w-full"
onClick={collect.crawl} onClick={() => void beginCollect()}
disabled={collect.confirmedCount === 0} disabled={isUploading || !hasSource}
> >
<Search /> <Search />
<span> <span>
{collect.confirmedCount === 0 {!hasSource
? '확정한 채널이 없습니다' ? '확정한 채널이 없습니다'
: `확정한 ${collect.confirmedCount}개 채널에서 수집하기`} : collect.confirmedCount > 0
? `확정한 ${collect.confirmedCount}개 채널에서 수집하기`
: '네이버 플레이스에서 정보 가져오기'}
</span> </span>
</Button> </Button>
</div> </div>

View File

@ -0,0 +1,81 @@
/**
* 화면에만 있던 신원을 **서버에 올린다** — 사업장 생성 + 네이버 주소 검증.
*
* ★ 왜 여기 모였나
* 위저드는 로그인을 수집 직전 한 번만 묻는다. 그래서 1단계의 확정(후보 선택·주소
* 붙여넣기)은 서버를 아예 부르지 않고 화면에만 남는다(usePlaceSearch). 그 값을
* 서버로 옮기는 일이 이 함수 하나에 모여 있어야, 로그인 뒤 순서(생성 → 검증 → 수집)가
* 한 곳에서만 정해진다. 나눠 두면 "사업장은 만들어졌는데 검증이 빠진" 상태가 생기고,
* 그러면 수집이 PLACE_NOT_VERIFIED 로 거절되면서 원인은 화면에 안 나온다.
*/
import {PlaceCategory as PlaceCategoryEnum, type IndustryType} from '@o2o/shared';
import type {PlaceCategory} from '@/api';
import {createPlace, verifyCandidates, verifyPlaceByUrl} from '@/api';
import {notifyApiError} from '@/lib/notify';
import type {ConfirmedIdentity} from '@/stores/builder';
const INDUSTRY_TO_CATEGORY: Record<IndustryType, PlaceCategory> = {
stay: PlaceCategoryEnum.LODGING,
cafe: PlaceCategoryEnum.CAFE,
restaurant: PlaceCategoryEnum.RESTAURANT,
clinic: PlaceCategoryEnum.CLINIC,
};
/**
* 서버에 사업장이 있는 신원을 돌려준다. 이미 있으면 그대로 통과한다.
* 실패는 null — 호출측이 수집을 시작하지 않는다.
*/
export async function ensureServerPlace(
identity: ConfirmedIdentity,
industry: IndustryType,
): Promise<ConfirmedIdentity | null> {
if (identity.placeId) return identity;
try {
const created = await createPlace({
name: identity.name || '내 가게',
category: INDUSTRY_TO_CATEGORY[industry],
});
const placeId = created.place?.place_id;
if (!placeId) {
notifyApiError({data: created}, '사업장을 만들지 못했습니다.');
return null;
}
// ★ 붙여넣은 주소가 없으면 **서버에 자동 발견을 먼저 시켜 본다.** 사장님이 직접
// 네이버 지도에 들어가 주소를 복사해 오는 건 마지막 수단이어야 한다.
// (실측 2026-09-03: 상호가 정확히 일치하면 여기서 플레이스 주소가 나온다.)
let url = identity.naverPlaceUrl;
if (!url) {
const query = [identity.name, identity.address].filter(Boolean).join(' ');
const found = await verifyCandidates(placeId, {query});
const hit =
found.candidates?.find((c) => c.naver_place_url && c.name === identity.name) ??
found.candidates?.find((c) => c.naver_place_url);
url = hit?.naver_place_url ?? undefined;
}
// 그래도 없으면 검증 없이 둔다 — 수집은 열리지 않고, 화면이 붙여넣기를 요청한다.
if (!url) return {...identity, placeId};
const res = await verifyPlaceByUrl(placeId, {url});
const place = res.place;
if (!place) {
notifyApiError({data: res}, '그 주소에서 가게 정보를 읽지 못했습니다.');
return {...identity, placeId};
}
// ★ 상호·주소는 서버가 읽은 값으로 덮는다. 사장님이 검색창에 친 이름이 아니라
// 네이버에 등록된 공식 표기가 이 사이트의 기준 정보가 되어야 한다.
return {
...identity,
placeId,
name: place.name ?? identity.name,
address: place.road_address ?? place.address ?? identity.address,
phone: place.phone ?? identity.phone,
externalPlaceId: place.external_place_id ?? identity.externalPlaceId,
naverPlaceUrl: url,
};
} catch (error) {
notifyApiError(error, '가게를 서버에 올리지 못했습니다.');
return null;
}
}

View File

@ -3,15 +3,11 @@ import type {IndustryType} from '@o2o/shared';
import type {PlaceCategory, PlaceSearchItem} from '@/api'; import type {PlaceCategory, PlaceSearchItem} from '@/api';
import { import {
createPlace, createPlace,
getAccessToken,
PlaceCategory as PlaceCategoryEnum, PlaceCategory as PlaceCategoryEnum,
searchPlacesPublic, searchPlacesPublic,
updatePlace, updatePlace,
verifyCandidates,
verifyPlaceByUrl,
} from '@/api'; } from '@/api';
import type {ConfirmedIdentity} from '@/stores/builder'; import type {ConfirmedIdentity} from '@/stores/builder';
import {ensureAutoSession} from '@/lib/autoSession';
import {describeError} from '@/lib/errorMessages'; import {describeError} from '@/lib/errorMessages';
import {notify, notifyApiError} from '@/lib/notify'; import {notify, notifyApiError} from '@/lib/notify';
@ -51,6 +47,8 @@ export interface PlaceSearchState {
pickError: string; pickError: string;
} }
/** 네이버 플레이스가 확정되지 않았을 때의 유일한 안내. 두 갈래(토큰 없음·자동 발견 실패)가
* 같은 문구를 써야 사장님이 볼 칸이 하나로 모인다. */
const INITIAL: PlaceSearchState = { const INITIAL: PlaceSearchState = {
phase: 'idle', phase: 'idle',
items: [], items: [],
@ -175,43 +173,26 @@ export function usePlaceSearch(existingPlaceId: string | null = null) {
* 네이버 플레이스 URL 로 확정한다. * 네이버 플레이스 URL 로 확정한다.
* *
* ★ 이 경로가 가장 확실하다. 상호 검색은 동명 업소·지점명 표기 차이에서 실패하고 * ★ 이 경로가 가장 확실하다. 상호 검색은 동명 업소·지점명 표기 차이에서 실패하고
* (실측: '롯데호텔 서울'), 검색모델은 네이버 플레이스를 아예 못 찾는다. * (실측: '롯데호텔 서울'), 자동 발견은 네이버가 검색 결과에서 플레이스 id 를 빼면서
* 반면 사장님은 자기 가게 주소를 이미 안다 — 붙여넣는 순간 신원·채널이 동시에 확정된다. * 2026-09-01 부터 아예 안 된다. 반면 사장님은 자기 가게 주소를 이미 안다.
* 상호·주소는 서버가 그 URL 에서 읽어 온다(손으로 옮겨 적게 하면 오타가 남의 가게가 된다).
*/ */
const confirmByUrl = useCallback( const confirmByUrl = useCallback(
async (url: string, industry: IndustryType): Promise<ConfirmedIdentity | null> => { async (url: string): Promise<ConfirmedIdentity | null> => {
setIsConfirming(true); setState((prev) => ({...prev, pickError: ''}));
try { // ★ 여기서 서버를 부르지 않는다. 사업장 생성·검증은 토큰을 요구하는데, 로그인은
const placeId = await ensurePlace( // **수집 직전 한 번만** 묻기로 했다. 주소만 신원에 실어 두고, 수집을 누를 때
nameRef.current || '내 가게', // 로그인 → 사업장 생성 → 이 주소로 검증 → 수집을 한 번에 보낸다.
INDUSTRY_TO_CATEGORY[industry], // 상호·주소는 그때 서버가 이 주소에서 읽어 덮어쓴다.
new AbortController().signal, return {
); placeId: placeIdRef.current,
if (!placeId) return null; name: nameRef.current || '내 가게',
const res = await verifyPlaceByUrl(placeId, {url}); address: '',
const place = res.place; origin: 'external',
if (!place) { sourceLabel: '네이버 플레이스',
notify.error('가게 정보를 읽지 못했습니다.', res.msg ?? '주소를 다시 확인해 주세요.'); naverPlaceUrl: url.trim(),
return null; };
}
return {
placeId,
name: place.name ?? '',
address: place.road_address ?? place.address ?? '',
phone: place.phone ?? undefined,
origin: 'external',
sourceLabel: '네이버 플레이스',
externalPlaceId: place.external_place_id ?? undefined,
};
} catch (error) {
notifyApiError(error, '이 주소로 확정하지 못했습니다.');
return null;
} finally {
setIsConfirming(false);
}
}, },
[ensurePlace], [],
); );
/** /**
@ -243,52 +224,21 @@ export function usePlaceSearch(existingPlaceId: string | null = null) {
const confirmPick = useCallback( const confirmPick = useCallback(
async ( async (
pick: {name: string; address: string}, pick: {name: string; address: string},
industry: IndustryType, _industry: IndustryType,
): Promise<ConfirmedIdentity | null> => { ): Promise<ConfirmedIdentity | null> => {
nameRef.current = pick.name.trim(); nameRef.current = pick.name.trim();
setState((prev) => ({...prev, pickError: ''})); setState((prev) => ({...prev, pickError: ''}));
// ★ 서버를 부르지 않는다(confirmByUrl 주석). 고른 후보의 상호·주소로 신원만 세우고,
// ★ 자동 로그인이 켜져 있으면 끝날 때까지 기다린다. 이걸 안 기다리면 페이지를 열자마자 // 사업장 생성·검증은 수집 직전 로그인 뒤에 한 번에 한다.
// 누른 확정이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다. return {
await ensureAutoSession(); placeId: placeIdRef.current,
if (!getAccessToken()) return confirmManual(pick.name, pick.address, '지도 검색'); name: pick.name.trim(),
address: pick.address.trim(),
setIsConfirming(true); origin: 'external',
try { sourceLabel: '지도 검색',
const controller = new AbortController(); };
const placeId = await ensurePlace(
pick.name.trim(),
INDUSTRY_TO_CATEGORY[industry],
controller.signal,
);
if (!placeId) throw new Error('사업장을 만들지 못했습니다.');
// 상호만으로는 동명 업소가 섞인다 — 사장님이 고른 그 주소까지 붙여 좁힌다.
const query = [pick.name.trim(), pick.address.trim()].filter(Boolean).join(' ');
const res = await verifyCandidates(placeId, {query}, undefined, controller.signal);
const candidates = res.candidates ?? [];
const hit =
candidates.find(
(c) => c.naver_place_url && normalizeName(c.name) === normalizeName(pick.name),
) ?? candidates.find((c) => c.naver_place_url);
if (!hit?.naver_place_url) {
setState((prev) => ({
...prev,
pickError:
'이 가게의 네이버 플레이스를 자동으로 찾지 못했습니다. 아래에서 지도 주소를 붙여넣어 주세요.',
}));
return null;
}
return await confirmByUrl(hit.naver_place_url, industry);
} catch (error) {
notifyApiError(error, '이 가게로 확정하지 못했습니다.');
return null;
} finally {
setIsConfirming(false);
}
}, },
[confirmByUrl, confirmManual, ensurePlace], [],
); );
return {...state, isConfirming, searchPublic, confirmPick, confirmByUrl, confirmManual, reset}; return {...state, isConfirming, searchPublic, confirmPick, confirmByUrl, confirmManual, reset};

View File

@ -22,9 +22,10 @@ export function LoginPage({selfServe = true}: {selfServe?: boolean}) {
const location = useLocation(); const location = useLocation();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
// 개발 편의로 기본값을 채워 둔다. 운영에 열기 전에 반드시 빈 문자열로 되돌린다. // ★ 비워 둔다. 채워 두면 번들에 그대로 구워져 나가고(실측 2026-09-03: 배포본에
const [id, setId] = useState('admin'); // admin/1234 가 박혀 있었다), 아이디만 고쳐 넣은 사람이 남의 비밀번호로 로그인을 시도한다.
const [password, setPassword] = useState('1234'); const [id, setId] = useState('');
const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// ★ 로그인을 마친 사람의 용건은 "내 것"이다 — 사장님 앱은 /sites 로 보낸다. // ★ 로그인을 마친 사람의 용건은 "내 것"이다 — 사장님 앱은 /sites 로 보낸다.

View File

@ -79,6 +79,9 @@ export interface ConfirmedIdentity {
externalPlaceId?: string; externalPlaceId?: string;
/** 후보가 들고 있던 공식 홈페이지. 확정 시 공식 채널로 등록된다. */ /** 후보가 들고 있던 공식 홈페이지. 확정 시 공식 채널로 등록된다. */
placeUrl?: string; placeUrl?: string;
/** 사장님이 붙여넣은 네이버 플레이스 주소. **아직 서버에 안 보냈다** —
* 수집 직전 로그인한 뒤에 이 값으로 사업장을 검증한다(usePlaceSearch.confirmByUrl). */
naverPlaceUrl?: string;
} }
export interface ApplyPlaceOptions { export interface ApplyPlaceOptions {

View File

@ -28,7 +28,7 @@ export default defineConfig({
// ★ Vite 6 는 모르는 Host 헤더를 403 "Blocked request" 로 막는다(DNS rebinding 방어). // ★ Vite 6 는 모르는 Host 헤더를 403 "Blocked request" 로 막는다(DNS rebinding 방어).
// 운영은 앞단 nginx 가 Host 를 그대로 넘기므로 발행 호스트를 여기 넣어야 화면이 뜬다. // 운영은 앞단 nginx 가 Host 를 그대로 넘기므로 발행 호스트를 여기 넣어야 화면이 뜬다.
// 빠뜨리면 프록시는 정상인데 앱만 전부 403 이다. // 빠뜨리면 프록시는 정상인데 앱만 전부 403 이다.
allowedHosts: [process.env.VITE_PUBLISH_HOST ?? 'w4ai.o2o.kr'], allowedHosts: [process.env.VITE_PUBLISH_HOST ?? 'web4ai.o2osolution.ai'],
// 발행 사이트는 별도 정적 서버(:3001)가 만들고 서빙하지만, 사용자는 admin 과 같은 // 발행 사이트는 별도 정적 서버(:3001)가 만들고 서빙하지만, 사용자는 admin 과 같은
// origin(:3000)의 `/s/<slug>` 로 접근한다. 프로세스 포트만 분리하고 공개 URL은 하나로 둔다. // origin(:3000)의 `/s/<slug>` 로 접근한다. 프로세스 포트만 분리하고 공개 URL은 하나로 둔다.
proxy: { proxy: {

View File

@ -52,7 +52,7 @@ export interface SiteMeta {
siteId: string; siteId: string;
placeId: string; placeId: string;
status: SiteStatus; status: SiteStatus;
/** 발행 도메인. 커스텀 도메인이 없으면 `<slug>.w4ai.o2o.kr` 같은 기본 호스트. */ /** 발행 도메인. 커스텀 도메인이 없으면 `<slug>.web4ai.o2osolution.ai` 같은 기본 호스트. */
origin: string; origin: string;
/** URL 경로 프리픽스. 루트 발행이면 '' (빈 문자열). */ /** URL 경로 프리픽스. 루트 발행이면 '' (빈 문자열). */
basePath: string; basePath: string;

View File

@ -93,7 +93,7 @@ export const MOONLIGHT_STAY_PAYLOAD: SitePayload = {
siteId: 'demo-site-0001', siteId: 'demo-site-0001',
placeId: 'demo-place-0001', placeId: 'demo-place-0001',
status: SiteStatus.PUBLISHED, status: SiteStatus.PUBLISHED,
origin: 'https://moonlight-stay-jeju.w4ai.o2o.kr', origin: 'https://moonlight-stay-jeju.web4ai.o2osolution.ai',
basePath: '', basePath: '',
slug: 'moonlight-stay-jeju', slug: 'moonlight-stay-jeju',
publishedAt: '2026-08-20T10:00:00+09:00', publishedAt: '2026-08-20T10:00:00+09:00',