[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:
parent
a67169dfa2
commit
238d4c25c4
@ -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=
|
||||||
|
|||||||
@ -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:-}
|
||||||
|
|||||||
36
solution/backend/common/utils/ttl_cache.py
Normal file
36
solution/backend/common/utils/ttl_cache.py
Normal 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()
|
||||||
@ -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):
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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,14 +366,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 />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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};
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
// ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록)
|
// ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user