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 도 같이 본다
# canonical·og:url·sitemap·IndexNow 가 전부 SITE_PUBLIC_HOST 를 쓴다.
SITE_PUBLIC_HOST=w4ai.o2o.kr
# 로컬은 비워 둔다(기본값 localhost). 서버에 올릴 때만 실제 도메인을 적는다.
# SITE_PUBLIC_HOST=web4ai.o2osolution.ai
INDEXNOW_KEY= # 비우면 색인 통보를 건너뛴다(발행은 정상)
AZURE_STORAGE_CONNECTION_STRING= # 비우면 로컬 발행만 한다
AZURE_STORAGE_CONTAINER=

View File

@ -35,7 +35,7 @@
`azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 —
**렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.**
→ `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 를 다시 만들어야 한다.

View File

@ -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:-w4ai.o2o.kr}
# ★ 기본값은 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:-w4ai.o2o.kr}
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:-w4ai.o2o.kr}
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:-w4ai.o2o.kr}
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:-}

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` 를 흘려보낸다 |
★ 프론트 `.env` 에 따로 적지 않는다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.

View File

@ -40,7 +40,7 @@
| 1차 업종 | 숙박, 법무법인, 성형외과 | 숙박, 카페, 음식점, 관광체험 | **반드시 사업 결정 필요.** 법률·의료를 넣으면 데이터 스키마뿐 아니라 규제·승인·감사 체계가 선행되어야 함 |
| 입력 방식 | 고객 원본 사이트를 depth 3·최대 200페이지 크롤 | 공식 API, 사용자 확정 URL, 정적 HTML; 플랫폼 우회 수집 금지 | 현재의 출처·동의·robots 원칙 유지. 대규모 원본 사이트 수집은 별도 제품 모드로 분리 |
| 동적 크롤링 | 정적 우선, Playwright 폴백 | 봇 탐지 우회로 변질될 수 있어 HeadlessAdapter 미등록·금지 | 포괄적인 Playwright 폴백은 채택하지 않음. 소유권이 검증된 고객 도메인에만 허용할지 법무·보안 결정 후 제한적으로 검토 |
| 발행 도메인 | 고객 도메인 서브패스 권장, 서브도메인 차선 | 기본 `w4ai.o2o.kr/s/<slug>`, custom domain 경로 미완성 | 설계서 방향이 검색 권위 측면에서 더 적합. 고객 도메인 연결·소유권 검증을 우선 과제로 추가 |
| 발행 도메인 | 고객 도메인 서브패스 권장, 서브도메인 차선 | 기본 `web4ai.o2osolution.ai/s/<slug>`, 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 관계만 점진적으로 확장 |

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

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 등).
**우리만 쓰는 서버가 아니다** — 포트와 디스크를 남의 것과 나눠 쓴다.
## 어디에 두나 — `/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 와 인증서는 이 레포 밖이고 인프라 담당이 잡는다.

View File

@ -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;

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 이면 못 정한 것이다 — 화면이 사장님에게 직접 고르게 한다.
# 값이 있어도 확정이 아니다. 화면은 언제나 바꿀 수 있게 둔다(경계 업종이 실제로 있다).
category: Optional[PlaceCategory] = None
# 자동으로 찾은 네이버 플레이스 주소. ★ 공개 페이지에서 읽은 값이라 우리 DB 것이 아니다.
# 못 찾으면 None — 화면이 그때만 사장님에게 지도 주소를 묻는다.
naver_place_url: Optional[str] = None
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://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("/")

View File

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

View File

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

View File

@ -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 "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 = {
@ -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:

View File

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

View File

@ -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<void>;
isBusy?: boolean;
/** 이 상호로 네이버 지도를 바로 연다 — 사장님이 새 탭에서 직접 검색어를 치지 않게. */
searchQuery?: string;
}) {
const [url, setUrl] = useState('');
const [error, setError] = useState<string | null>(null);
@ -88,6 +91,20 @@ export function ChannelUrlInput({
까지 그대로 가져옵니다.
</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">
{HOW_TO.map((step, index) => (
<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"
disabled={isBusy}
/>
{/* ★ '추가' 가 아니다. 이 버튼은 등록에서 끝나지 않고 **그 주소에서 정보를 가져온다.**
'추가' 라고 쓰면 사장님은 목록에 한 줄 늘기를 기다리다가 아무 변화가 없어 실패로 읽는다. */}
<Button size="sm" variant="primary" onClick={() => void submit()} disabled={isBusy || !url.trim()}>
{isBusy ? <Loader2 className="animate-spin" /> : <Plus />}
<span>추가</span>
{isBusy ? <Loader2 className="animate-spin" /> : <Search />}
<span>{isBusy ? '가져오는 중' : '이 주소로 가져오기'}</span>
</Button>
</div>

View File

@ -65,6 +65,15 @@ export function Step2PlaceSearch() {
const search = usePlaceSearch(confirmedIdentity?.placeId ?? null);
const [step, goToStep] = useWizardStep();
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 과 같은 조건이다 — 여기서 더 느슨하면 눌러도 검색이 안 나가고
// 화면만 후보 단계로 넘어간다(빈 목록).
@ -90,7 +99,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);
};
@ -147,6 +159,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);
@ -180,8 +195,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);
};
@ -220,7 +236,32 @@ export function Step2PlaceSearch() {
? 'unavailable'
: '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 isPicked = (item: PlaceSearchItem) =>
Boolean(confirmedIdentity) &&
item.name === confirmedIdentity?.name &&
(item.road_address ?? '') === (confirmedIdentity?.address ?? '');
return (
<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',
)}
>
{search.items.length > 0
? '아래 목록에서 사장님 가게를 골라 주세요.'
: '이 이름으로는 찾지 못했습니다. 아래에서 네이버 지도 주소로 찾아 주세요.'}
{search.items.length === 0
? '이 이름으로는 찾지 못했습니다. 아래에서 네이버 지도 주소로 찾아 주세요.'
: foundCount > 0
? `${foundCount}곳은 네이버 플레이스까지 찾았습니다 — 고르시면 영업시간·전화·사진을 그대로 가져옵니다.`
: '아래 목록에서 사장님 가게를 골라 주세요.'}
</p>
{search.pickError && (
@ -335,14 +378,33 @@ export function Step2PlaceSearch() {
)}
{/* 고르기 전에 어떤 업종으로 시작하는지 먼저 보여준다 — 고른 뒤에 알면 늦다. */}
<IndustryBadge category={item.category} />
{/* ★ 플레이스를 찾았는지 **여기서** 보여준다. 데이터만 싣고 표시를 안 하면
사장님은 자동으로 찾았는지 알 길이 없어 지도 주소를 또 찾으러 간다. */}
{item.naver_place_url && (
<Badge variant="success" className="gap-1 text-[10px]">
<Check className="size-3" />
네이버 플레이스 찾음
</Badge>
)}
</div>
<p className="mt-1.5 flex items-start gap-1 text-xs text-muted-foreground">
<MapPin className="mt-px size-3 shrink-0" />
<span>{item.road_address ?? '주소 없음'}</span>
</p>
</div>
<span className="shrink-0 rounded-lg bg-primary px-3 py-1.5 text-[11px] font-bold text-primary-foreground">
{search.isConfirming ? '확정 중...' : '이 가게예요'}
<span
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>
</div>
</button>
@ -357,14 +419,24 @@ export function Step2PlaceSearch() {
onChange={changeIndustry}
/>
<PlaceUrlBox
value={placeUrl}
onChange={setPlaceUrl}
onSubmit={() => void pickByUrl()}
isBusy={search.isConfirming}
compact={search.items.length > 0 && !search.pickError}
searchQuery={searchQuery}
/>
{search.items.length > 0 && !urlBoxOpen && !search.pickError ? (
<button
type="button"
onClick={() => setUrlBoxOpen(true)}
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"
>
목록에 내 가게가 없나요? — 네이버 지도 주소로 찾기
</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}>
<Search />

View File

@ -6,8 +6,13 @@ import {Input} from '@/components/ui/input';
import {Switch} from '@/components/ui/switch';
import {INDUSTRY_CONFIGS} from '@/data/industryData';
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 {ChannelConfirmPanel} from './ChannelConfirmPanel';
import {ensureServerPlace} from './ensureServerPlace';
import {ChannelUrlInput} from './ChannelUrlInput';
import {useCollectFlow} from './useCollectFlow';
import {STEP_NUMBER, useWizardStep} from './wizardUrl';
@ -40,9 +45,67 @@ export function Step3DataReview() {
const photos = useBuilderStore((s) => s.photos);
const [, goToStep] = useWizardStep();
const confirmIdentity = useBuilderStore((s) => s.confirmIdentity);
const user = useAuthStore((s) => s.user);
const unverified = useUnverifiedFields();
const placeId = confirmedIdentity?.placeId ?? null;
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 startAutomaticCollect = collect.crawl;
const autoCollectStarted = useRef(false);
@ -75,6 +138,15 @@ export function Step3DataReview() {
const config = INDUSTRY_CONFIGS[industry];
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';
@ -89,13 +161,52 @@ export function Step3DataReview() {
? 'review'
: '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 (
<div className="flex flex-1 flex-col justify-between bg-muted/30">
<WizardStage
current={STEP_NUMBER.collect}
wide={stage === 'review' || stage === 'channels'}
title={STAGE_TITLE[stage]}
description={STAGE_DESCRIPTION[stage]}
title={stage === 'ready' && pendingUrl ? '네이버 플레이스를 찾았습니다' : STAGE_TITLE[stage]}
description={
stage === 'ready' && pendingUrl
? '이 가게에서 영업시간·전화·편의시설과 사진을 그대로 가져옵니다.'
: STAGE_DESCRIPTION[stage]
}
>
{stage === 'ready' && (
<div className="space-y-3">
@ -113,22 +224,52 @@ export function Step3DataReview() {
</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 && (
<Button
variant="primary"
size="lg"
className="w-full"
onClick={collect.crawl}
disabled={collect.confirmedCount === 0}
onClick={() => void beginCollect()}
disabled={isUploading || !hasSource}
>
<Search />
<span>
{collect.confirmedCount === 0
{!hasSource
? '주소를 먼저 붙여넣어 주세요'
: `등록한 ${collect.confirmedCount}개 주소에서 정보 가져오기`}
: collect.confirmedCount > 0
? `등록한 ${collect.confirmedCount}개 주소에서 정보 가져오기`
: isUploading
? '내 가게를 찾는 중입니다...'
: '내 가게 정보 자동으로 가져오기'}
</span>
</Button>
)}
@ -217,20 +358,26 @@ export function Step3DataReview() {
/>
</div>
<ChannelUrlInput onAdd={collect.addLink} isBusy={collect.isAdding} />
<ChannelUrlInput
onAdd={collect.addLink}
isBusy={collect.isAdding}
searchQuery={[confirmedIdentity?.name, confirmedIdentity?.address].filter(Boolean).join(' ')}
/>
<Button
variant="primary"
size="lg"
className="w-full"
onClick={collect.crawl}
disabled={collect.confirmedCount === 0}
onClick={() => void beginCollect()}
disabled={isUploading || !hasSource}
>
<Search />
<span>
{collect.confirmedCount === 0
{!hasSource
? '확정한 채널이 없습니다'
: `확정한 ${collect.confirmedCount}개 채널에서 수집하기`}
: collect.confirmedCount > 0
? `확정한 ${collect.confirmedCount}개 채널에서 수집하기`
: '네이버 플레이스에서 정보 가져오기'}
</span>
</Button>
</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 {
createPlace,
getAccessToken,
PlaceCategory as PlaceCategoryEnum,
searchPlacesPublic,
updatePlace,
verifyCandidates,
verifyPlaceByUrl,
} from '@/api';
import type {ConfirmedIdentity} from '@/stores/builder';
import {ensureAutoSession} from '@/lib/autoSession';
import {describeError} from '@/lib/errorMessages';
import {notify, notifyApiError} from '@/lib/notify';
@ -51,6 +47,8 @@ export interface PlaceSearchState {
pickError: string;
}
/** 네이버 플레이스가 확정되지 않았을 때의 유일한 안내. 두 갈래(토큰 없음·자동 발견 실패)가
* 같은 문구를 써야 사장님이 볼 칸이 하나로 모인다. */
const INITIAL: PlaceSearchState = {
phase: 'idle',
items: [],
@ -175,43 +173,26 @@ export function usePlaceSearch(existingPlaceId: string | null = null) {
* 네이버 플레이스 URL 로 확정한다.
*
* ★ 이 경로가 가장 확실하다. 상호 검색은 동명 업소·지점명 표기 차이에서 실패하고
* (실측: '롯데호텔 서울'), 검색모델은 네이버 플레이스를 아예 못 찾는다.
* 반면 사장님은 자기 가게 주소를 이미 안다 — 붙여넣는 순간 신원·채널이 동시에 확정된다.
* 상호·주소는 서버가 그 URL 에서 읽어 온다(손으로 옮겨 적게 하면 오타가 남의 가게가 된다).
* (실측: '롯데호텔 서울'), 자동 발견은 네이버가 검색 결과에서 플레이스 id 를 빼면서
* 2026-09-01 부터 아예 안 된다. 반면 사장님은 자기 가게 주소를 이미 안다.
*/
const confirmByUrl = useCallback(
async (url: string, industry: IndustryType): Promise<ConfirmedIdentity | null> => {
setIsConfirming(true);
try {
const placeId = await ensurePlace(
nameRef.current || '내 가게',
INDUSTRY_TO_CATEGORY[industry],
new AbortController().signal,
);
if (!placeId) return null;
const res = await verifyPlaceByUrl(placeId, {url});
const place = res.place;
if (!place) {
notify.error('가게 정보를 읽지 못했습니다.', res.msg ?? '주소를 다시 확인해 주세요.');
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);
}
async (url: string): Promise<ConfirmedIdentity | null> => {
setState((prev) => ({...prev, pickError: ''}));
// ★ 여기서 서버를 부르지 않는다. 사업장 생성·검증은 토큰을 요구하는데, 로그인은
// **수집 직전 한 번만** 묻기로 했다. 주소만 신원에 실어 두고, 수집을 누를 때
// 로그인 → 사업장 생성 → 이 주소로 검증 → 수집을 한 번에 보낸다.
// 상호·주소는 그때 서버가 이 주소에서 읽어 덮어쓴다.
return {
placeId: placeIdRef.current,
name: nameRef.current || '내 가게',
address: '',
origin: 'external',
sourceLabel: '네이버 플레이스',
naverPlaceUrl: url.trim(),
};
},
[ensurePlace],
[],
);
/**
@ -243,52 +224,21 @@ export function usePlaceSearch(existingPlaceId: string | null = null) {
const confirmPick = useCallback(
async (
pick: {name: string; address: string},
industry: IndustryType,
_industry: IndustryType,
): Promise<ConfirmedIdentity | null> => {
nameRef.current = pick.name.trim();
setState((prev) => ({...prev, pickError: ''}));
// ★ 자동 로그인이 켜져 있으면 끝날 때까지 기다린다. 이걸 안 기다리면 페이지를 열자마자
// 누른 확정이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
await ensureAutoSession();
if (!getAccessToken()) return confirmManual(pick.name, pick.address, '지도 검색');
setIsConfirming(true);
try {
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 주석). 고른 후보의 상호·주소로 신원만 세우고,
// 사업장 생성·검증은 수집 직전 로그인 뒤에 한 번에 한다.
return {
placeId: placeIdRef.current,
name: pick.name.trim(),
address: pick.address.trim(),
origin: 'external',
sourceLabel: '지도 검색',
};
},
[confirmByUrl, confirmManual, ensurePlace],
[],
);
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 user = useAuthStore((s) => s.user);
// 개발 편의로 기본값을 채워 둔다. 운영에 열기 전에 반드시 빈 문자열로 되돌린다.
const [id, setId] = useState('admin');
const [password, setPassword] = useState('1234');
// ★ 비워 둔다. 채워 두면 번들에 그대로 구워져 나가고(실측 2026-09-03: 배포본에
// admin/1234 가 박혀 있었다), 아이디만 고쳐 넣은 사람이 남의 비밀번호로 로그인을 시도한다.
const [id, setId] = useState('');
const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
// ★ 로그인을 마친 사람의 용건은 "내 것"이다 — 사장님 앱은 /sites 로 보낸다.

View File

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

View File

@ -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/<slug>` 로 접근한다. 프로세스 포트만 분리하고 공개 URL은 하나로 둔다.
proxy: {

View File

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

View File

@ -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',