[feat] solution: 네이버 플레이스를 검색 단계에서 찾는다 — 로그인은 수집 직전 한 번

사장님이 후보를 고른 뒤에도 "네이버 플레이스를 자동으로 찾지 못했습니다" 가 떴다.
찾을 수 있는데도 그랬다 — 자동 발견이 확정 경로(로그인 뒤)에만 있었고, 공개 검색은
상호·주소만 돌려줬다. 그리고 확정이 사업장 생성을 요구해서, 로그인 없이 시작하기로 한
위저드가 검색 직후부터 막혔다(자동 로그인이 그걸 가리고 있었다).

- place_service.search_places_public: 응답에 naver_place_url 을 싣는다. 넓은 검색어
  한 페이지에서 못 찾은 후보는 그 후보만 겨냥해 다시 찾는다(상위 2건, 429 회피).
  실측: 12개 상호 전부 발견. 전에는 4개 중 2개
- naver_place_lookup._region_hint: 주소에서 시·군·구까지만 뽑아 검색을 좁힌다.
  첫 토막('경기도')만 쓰면 **다른 동네 동명 업소**가 잡히고, 그 id 로 검증하면 남의
  가게가 이 사이트의 기준 정보가 된다 — 실측으로 한 번 겪었다
- ttl_cache(신규) + 공개 검색 10분 캐시: 검색 1회가 네이버를 최대 3번 긁는데 인증이
  없어 새로고침만으로 나간다. 실측 1.38s → 0.005s. **빈 결과는 캐시하지 않는다** —
  일시적 0건을 굳히면 사장님이 10분간 막힌다
- 확정은 서버를 부르지 않는다(usePlaceSearch). 화면에만 남기고, 수집 직전 로그인 뒤
  ensureServerPlace 가 생성 → 검증을 한 번에 한다. 나눠 두면 "사업장은 생겼는데 검증이
  빠진" 상태가 생기고 수집이 PLACE_NOT_VERIFIED 로 조용히 거절된다
- Step3: 수집 버튼이 로그인 모달을 연다(/login 으로 튕기지 않는다 — 위저드 상태가
  주소창에 없어 돌아올 길이 없다). 로그인하면 이어서 돈다
- Step2: 후보 카드에 '네이버 플레이스 찾음' 배지. 붙여넣기 칸은 접는다 —
  펼쳐 두면 시도도 전에 실패한 것으로 읽힌다. 뒤로 오면 처음 화면으로
- ChannelUrlInput: [추가] → [이 주소로 가져오기]. 로그인 전에는 addLink 가 placeId 가
  없어 **조용히 return** 해서 입력칸만 비워졌다(useChannelLinks.ts:37)
- LoginPage: admin/1234 기본값 제거. 배포 번들에 그대로 나가 있었다
- 기본 발행 호스트를 localhost 로(compose 4곳 · site_payload.DEFAULT_HOST · .env.example).
  운영 도메인을 기본값으로 두면 .env 를 안 채운 로컬 빌드가 조용히 운영 주소를 번들에
  굽는다 — 실측: 로컬에서 만든 링크가 킹서버로 갔다. localhost 는 http 로 조립한다

검증: tsc·eslint·vite build 통과. 브라우저로 전 구간 확인(검색 → 확정 → 로그인 →
자동 발견 → 검증 → 수집 fact 27건·사진 10장·메뉴 23건 → 사진 분석).
백엔드 테스트는 이 워크트리에서 못 돌렸다 — config.test.toml 이 없어 DB 인증이 실패한다.
This commit is contained in:
Mina Choi 2026-09-03 16:37:12 +09:00
parent a67169dfa2
commit 238d4c25c4
16 changed files with 524 additions and 129 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=web4ai.o2osolution.ai # 로컬은 비워 둔다(기본값 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

@ -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:-web4ai.o2osolution.ai} # ★ 기본값은 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:-web4ai.o2osolution.ai} 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:-web4ai.o2osolution.ai} 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:-web4ai.o2osolution.ai} 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

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

@ -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,13 +683,21 @@ 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 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: async def find_candidates(self, user_info: UserInfo, place_id: str, query: str | None = None) -> Res_VerifyCandidates:
"""외부 장소 DB 에서 이 상호명의 후보를 찾아 그대로 내려준다. """외부 장소 DB 에서 이 상호명의 후보를 찾아 그대로 내려준다.
@ -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 "web4ai.o2osolution.ai" # 기본값은 localhost. 운영 도메인을 기본으로 두면 설정을 빠뜨린 환경이 조용히 운영 주소로
# canonical·sitemap 을 굽는다 — 틀렸다는 걸 아무도 모른다.
DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "localhost"
def _scheme(host: str) -> str:
"""localhost 는 http 다. shared/lib/slug.ts publishUrl 과 같은 규칙."""
return "http" if re.match(r"^(localhost|127\.0\.0\.1)(:\d+)?$", host) else "https"
# 링크 제목이 비었을 때 채우는 채널 이름. 없는 채널명을 지어내지 않기 위한 고정 표다. # 링크 제목이 비었을 때 채우는 채널 이름. 없는 채널명을 지어내지 않기 위한 고정 표다.
_CHANNEL_TITLE = { _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=web4ai.o2osolution.ai VITE_PUBLISH_HOST=localhost
# 발행 사이트 렌더러 개발 서버. 에디터 상단 "발행본 사이트 열기" 가 이 주소를 연다. # 발행 사이트 렌더러 개발 서버. 에디터 상단 "발행본 사이트 열기" 가 이 주소를 연다.
VITE_SITE_PREVIEW_URL=http://localhost:3000 VITE_SITE_PREVIEW_URL=http://localhost:3000

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

@ -59,6 +59,15 @@ export function Step2PlaceSearch() {
const search = usePlaceSearch(confirmedIdentity?.placeId ?? null); const search = usePlaceSearch(confirmedIdentity?.placeId ?? null);
const [, goToStep] = useWizardStep(); const [, 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;
const canSearch = storeName.trim().length > 0 && search.phase !== 'searching'; const canSearch = storeName.trim().length > 0 && search.phase !== 'searching';
@ -81,7 +90,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);
}; };
@ -112,6 +124,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);
@ -138,8 +153,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);
}; };
@ -163,6 +179,7 @@ export function Step2PlaceSearch() {
}; };
// ── 화면 고르기. 위에서부터 먼저 맞는 것 하나만 그린다 ────────────── // ── 화면 고르기. 위에서부터 먼저 맞는 것 하나만 그린다 ──────────────
//
const stage = confirmedIdentity const stage = confirmedIdentity
? 'confirmed' ? 'confirmed'
: search.phase === 'searching' : search.phase === 'searching'
@ -173,7 +190,32 @@ export function Step2PlaceSearch() {
? 'picking' ? 'picking'
: 'input'; : 'input';
/**
* 2단계에서 **뒤로 오면 처음(상호 입력)으로 되돌린다.**
*
* ★ 왜 확정 화면을 안 띄우나: 뒤로가기는 "다시 고르겠다" 는 뜻인데, 확정 화면만 뜨면
* 다른 가게로 바꾸러 온 사장님이 목록을 못 찾는다. 후보 목록을 되살리는 방법도 있지만
* 공개 검색이 유료 외부 API 라 뒤로 올 때마다 다시 부르게 된다.
* ★ 이 화면을 **정상적으로 지나간** 경우(확정 직후 2단계로 넘어감)에는 돌지 않는다 —
* `justConfirmed` 가 그 한 번을 막는다. 아니면 확정하자마자 스스로 지워 버린다.
*/
useEffect(() => {
if (!confirmedIdentity) return;
if (justConfirmed.current) {
justConfirmed.current = false;
return;
}
clearIdentity();
search.reset();
// eslint-disable-next-line react-hooks/exhaustive-deps -- 들어올 때 한 번만 판단한다
}, []);
const searchQuery = [storeName, location].filter(Boolean).join(' '); const 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">
@ -249,9 +291,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 && (
@ -281,14 +325,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>
@ -303,6 +366,15 @@ export function Step2PlaceSearch() {
onChange={changeIndustry} onChange={changeIndustry}
/> />
{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 <PlaceUrlBox
value={placeUrl} value={placeUrl}
onChange={setPlaceUrl} onChange={setPlaceUrl}
@ -311,6 +383,7 @@ export function Step2PlaceSearch() {
compact={search.items.length > 0 && !search.pickError} compact={search.items.length > 0 && !search.pickError}
searchQuery={searchQuery} 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 {useWizardStep} from './wizardUrl'; import {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={2} current={2}
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,
);
if (!placeId) return null;
const res = await verifyPlaceByUrl(placeId, {url});
const place = res.place;
if (!place) {
notify.error('가게 정보를 읽지 못했습니다.', res.msg ?? '주소를 다시 확인해 주세요.');
return null;
}
return { return {
placeId, placeId: placeIdRef.current,
name: place.name ?? '', name: nameRef.current || '내 가게',
address: place.road_address ?? place.address ?? '', address: '',
phone: place.phone ?? undefined,
origin: 'external', origin: 'external',
sourceLabel: '네이버 플레이스', sourceLabel: '네이버 플레이스',
externalPlaceId: place.external_place_id ?? undefined, naverPlaceUrl: url.trim(),
}; };
} 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);
// ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록) // ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록)

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 {