[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 도 같이 본다
|
||||
# canonical·og:url·sitemap·IndexNow 가 전부 SITE_PUBLIC_HOST 를 쓴다.
|
||||
SITE_PUBLIC_HOST=web4ai.o2osolution.ai
|
||||
# 로컬은 비워 둔다(기본값 localhost). 서버에 올릴 때만 실제 도메인을 적는다.
|
||||
# SITE_PUBLIC_HOST=web4ai.o2osolution.ai
|
||||
INDEXNOW_KEY= # 비우면 색인 통보를 건너뛴다(발행은 정상)
|
||||
AZURE_STORAGE_CONNECTION_STRING= # 비우면 로컬 발행만 한다
|
||||
AZURE_STORAGE_CONTAINER=
|
||||
|
||||
@ -25,7 +25,9 @@ x-common-env: &common-env
|
||||
# 수신자(aud)를 대조한다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다.
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||
# ★ 프론트(VITE_PUBLISH_HOST)와 같은 값이어야 한다. canonical·og:url·sitemap 이 전부 이걸 쓴다.
|
||||
SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-web4ai.o2osolution.ai}
|
||||
# ★ 기본값은 localhost 다. 운영 도메인을 기본으로 두면 .env 를 안 채운 로컬 빌드가
|
||||
# 조용히 운영 주소를 번들에 굽는다(실측 2026-09-03: 로컬 링크가 킹서버로 갔다).
|
||||
SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
||||
SITE_PAYLOAD_DIR: /app/out/payloads
|
||||
SITE_OUTPUT_DIR: /app/out/sites
|
||||
# ★ 프리렌더와 같은 값이어야 한다. 어긋나면 색인 통보가 403 이다.
|
||||
@ -148,7 +150,7 @@ services:
|
||||
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
|
||||
# ★ 발행 호스트를 프론트 .env 에 따로 적지 않는다 — 루트 .env 의 SITE_PUBLIC_HOST 를
|
||||
# 그대로 흘려보낸다. 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
|
||||
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-web4ai.o2osolution.ai}
|
||||
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
||||
volumes:
|
||||
- ./package.json:/app/package.json
|
||||
- ./package-lock.json:/app/package-lock.json
|
||||
@ -186,11 +188,11 @@ services:
|
||||
environment:
|
||||
PORT: 3001
|
||||
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
|
||||
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-web4ai.o2osolution.ai}
|
||||
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
||||
# ★ 이 둘은 **브라우저가** 부르는 주소다. 컨테이너 안에서 보는 주소가 아니라
|
||||
# 화면을 연 사람이 닿을 수 있는 주소여야 한다 — localhost 는 서버에 올리는 순간 틀린다.
|
||||
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800}
|
||||
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000}
|
||||
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
|
||||
VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-}
|
||||
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
|
||||
# 백엔드와 같은 값을 흘려보낸다(루트 .env 가 단일 출처).
|
||||
@ -231,7 +233,7 @@ services:
|
||||
VITE_API_BASE_URL: ${ADMIN_API_BASE_URL:-http://localhost:9801}
|
||||
# 사장님 앱은 다른 오리진이라 절대 URL 로 연다(admin/frontend/src/lib/solutionUrl.ts).
|
||||
# 이걸 안 넘기면 "빌더 열기" 가 서버에서 localhost:3000 을 가리켜 죽은 링크가 된다.
|
||||
VITE_SOLUTION_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000}
|
||||
VITE_SOLUTION_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
|
||||
volumes:
|
||||
- ./package.json:/app/package.json
|
||||
- ./package-lock.json:/app/package-lock.json
|
||||
@ -259,8 +261,8 @@ services:
|
||||
# ★ VITE_* 는 **번들에 구워진다.** .env 를 고쳐도 재빌드 전엔 안 바뀐다
|
||||
# → 주소를 바꿨으면 `./deploy.sh solution-site`.
|
||||
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800}
|
||||
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-web4ai.o2osolution.ai}
|
||||
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000}
|
||||
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
||||
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
|
||||
# ⚠️ 비어 있으면 자동 로그인은 아예 꺼진다(기본값 없음). 채우면 번들에 구워진다.
|
||||
VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-}
|
||||
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
|
||||
|
||||
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 이면 못 정한 것이다 — 화면이 사장님에게 직접 고르게 한다.
|
||||
# 값이 있어도 확정이 아니다. 화면은 언제나 바꿀 수 있게 둔다(경계 업종이 실제로 있다).
|
||||
category: Optional[PlaceCategory] = None
|
||||
# 자동으로 찾은 네이버 플레이스 주소. ★ 공개 페이지에서 읽은 값이라 우리 DB 것이 아니다.
|
||||
# 못 찾으면 None — 화면이 그때만 사장님에게 지도 주소를 묻는다.
|
||||
naver_place_url: Optional[str] = None
|
||||
|
||||
|
||||
class Res_PlaceSearch(Res_WebPacketProtocol):
|
||||
|
||||
@ -52,6 +52,20 @@ def _normalize(text: str) -> str:
|
||||
return re.sub(r"[\s,·.\-_'\"()&]", "", (text or "")).lower()
|
||||
|
||||
|
||||
def _region_hint(address: Optional[str]) -> str:
|
||||
"""주소에서 검색을 좁힐 지역 토막. 시/군/구까지만 쓴다.
|
||||
|
||||
★ 첫 토막만 쓰면 안 된다 — 그건 광역시·도('경기도')라 오히려 넓어진다.
|
||||
실측(2026-09-03 '버터브루'): '버터브루 경기도' 로 찾으면 **다른 동네 동명 업소**의
|
||||
id 가 잡히고, 그 id 로 검증하면 남의 가게가 이 사이트의 기준 정보가 된다.
|
||||
'버터브루 성남시 중원구' 로 좁히면 정확히 잡힌다.
|
||||
★ 도로명·번지는 넣지 않는다. 검색 결과가 그 주소를 언급한 블로그로 채워져 id 가 사라진다.
|
||||
"""
|
||||
tokens = (address or "").split()
|
||||
picked = [t for t in tokens if t.endswith(("시", "군", "구"))]
|
||||
return " ".join(picked[:2])
|
||||
|
||||
|
||||
async def _fetch_search_html(query: str) -> Optional[str]:
|
||||
"""통합검색 결과 페이지 원문. 실패는 None — 호출측이 조용히 폴백한다."""
|
||||
try:
|
||||
@ -114,7 +128,7 @@ async def find_place_id(name: str, address: Optional[str] = None) -> Optional[st
|
||||
★ 이름이 일치하는 후보만 받는다. '비슷한 것 중 첫 번째'를 고르면 남의 가게를
|
||||
이 가게의 공식 채널로 등록하게 된다 — 이 제품에서 가장 비싼 실수다.
|
||||
"""
|
||||
query = " ".join(x for x in (name, (address or "").split()[0] if address else "") if x)
|
||||
query = " ".join(x for x in (name, _region_hint(address)) if x)
|
||||
html = await _fetch_search_html(query)
|
||||
if not html:
|
||||
return None
|
||||
|
||||
@ -50,6 +50,12 @@ from services.external.naver import region_key
|
||||
from services.job_service import enqueue_job
|
||||
|
||||
|
||||
# 겨냥 조회를 몇 건까지 할지. 후보 전부(5건)를 부르면 통합검색이 429 를 준다.
|
||||
_TARGETED_LOOKUP_LIMIT = 2
|
||||
# 공개 검색 캐시 수명. 가게 정보가 이 안에 바뀔 일은 없다.
|
||||
_PUBLIC_SEARCH_CACHE_SEC = 600
|
||||
|
||||
|
||||
class PlaceService:
|
||||
"""사업장 등록·조회·동일 업소 검증.
|
||||
|
||||
@ -605,9 +611,10 @@ class PlaceService:
|
||||
★ DB 를 읽지도 쓰지도 않는다. 나가는 값은 외부 장소 DB 가 공개적으로 주는 것뿐이다.
|
||||
"""
|
||||
from common.enums import ExternalPlaceSource
|
||||
from common.utils import rate_limit
|
||||
from common.utils import rate_limit, ttl_cache
|
||||
from services.external import kakao as kakao_client
|
||||
from services.external import naver as naver_client
|
||||
from services.external import naver_place_lookup
|
||||
from services.place_category import guess_category
|
||||
|
||||
res = Res_PlaceSearch()
|
||||
@ -616,6 +623,14 @@ class PlaceService:
|
||||
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||||
return res
|
||||
|
||||
# 같은 검색어는 캐시로 받는다. 사장님이 상호를 고쳐 가며 대여섯 번 치는 동안
|
||||
# 네이버를 매번 세 번씩 긁지 않게 한다.
|
||||
cache_key = f"place-search:{q}"
|
||||
cached = ttl_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
res.source, res.items = cached
|
||||
return res
|
||||
|
||||
# ★ 인증이 없는데 유료 외부 API 를 부른다 — 방어가 0 이면 새로고침만으로 요금이 나간다.
|
||||
# 프로세스 메모리 기반이라 완전하지 않다(common/utils/rate_limit.py 주석).
|
||||
if not rate_limit.allow(f"place-search:{client_ip}", _PUBLIC_SEARCH_PER_MIN, 60.0):
|
||||
@ -644,6 +659,23 @@ class PlaceService:
|
||||
res.result.SetResult(ErrorType.LOCAL_FETCH_FAILED)
|
||||
return res
|
||||
|
||||
# ★ 여기서 네이버 플레이스 주소까지 찾아 실어 보낸다.
|
||||
# 전에는 확정 경로(verify/candidates)에서만 찾았는데, 그건 로그인 뒤라 사장님이
|
||||
# 후보를 고를 때는 "이 가게의 플레이스가 있는지" 를 알 수 없었다. 화면은 그걸
|
||||
# "자동으로 못 찾았다" 로 읽고 지도 주소를 물었다 — 찾을 수 있는데도.
|
||||
# 실패는 조용히 넘긴다(None). 유료 API 가 아니라 공개 페이지 조회다.
|
||||
naver_ids: dict[str, str] = {}
|
||||
try:
|
||||
naver_ids = await naver_place_lookup.find_place_ids(q, [r.name for r in rows])
|
||||
for row in rows[:_TARGETED_LOOKUP_LIMIT]:
|
||||
if row.name in naver_ids:
|
||||
continue
|
||||
found = await naver_place_lookup.find_place_id(row.name, row.road_address or row.address)
|
||||
if found:
|
||||
naver_ids[row.name] = found
|
||||
except Exception as ex: # noqa: BLE001 — 지도 주소 붙여넣기로 이어진다
|
||||
LOG.w(f"[search] 플레이스 자동 발견 실패(후보는 유지): {type(ex).__name__}: {ex}")
|
||||
|
||||
res.items = [
|
||||
PlaceSearchItem(
|
||||
name=row.name,
|
||||
@ -651,13 +683,21 @@ class PlaceService:
|
||||
category_name=row.category_name,
|
||||
# 네이버는 그룹코드를 주지 않는다 — 그때는 분류 문자열만으로 추정한다.
|
||||
category=guess_category(row.category_name, getattr(row, "category_group_code", None)),
|
||||
naver_place_url=(
|
||||
naver_place_lookup.place_url(naver_ids[row.name]) if row.name in naver_ids else None
|
||||
),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
if not res.items:
|
||||
# ★ 빈 결과는 캐시하지 않는다. 네이버가 잠깐 막아서 0건이 나온 것을 굳히면
|
||||
# 사장님은 TTL 이 끝날 때까지 아무것도 못 한다.
|
||||
res.result.SetResult(ErrorType.PLACE_VERIFY_NO_CANDIDATE)
|
||||
return res
|
||||
|
||||
ttl_cache.put(cache_key, (res.source, res.items), _PUBLIC_SEARCH_CACHE_SEC)
|
||||
return res
|
||||
|
||||
async def find_candidates(self, user_info: UserInfo, place_id: str, query: str | None = None) -> Res_VerifyCandidates:
|
||||
"""외부 장소 DB 에서 이 상호명의 후보를 찾아 그대로 내려준다.
|
||||
|
||||
@ -731,6 +771,22 @@ class PlaceService:
|
||||
except Exception as ex: # noqa: BLE001 — 지도 URL 직접 입력으로 이어진다
|
||||
LOG.w(f"[verify] 네이버 플레이스 자동 발견 실패(후보는 유지): {type(ex).__name__}: {ex}")
|
||||
naver_ids = {}
|
||||
# ★ 넓은 검색어 한 페이지에서 못 찾은 후보는 **그 후보만 겨냥해** 한 번 더 찾는다.
|
||||
# 넓은 검색은 결과가 다른 지점으로 채워져 이름이 아예 안 실릴 때가 있다 —
|
||||
# 실측(2026-09-03 '스타벅스 판교역점'): 페이지에 id 6개가 있는데 지역검색이 준
|
||||
# 5개 지점명은 하나도 그 근처에 없었다. 상호+지역으로 겨냥하면 4/4 로 찾는다.
|
||||
# 호출은 상위 후보 몇 건으로 끊는다 — 5건을 다 부르면 429 를 받는다.
|
||||
for c in rows[:_TARGETED_LOOKUP_LIMIT]:
|
||||
if c.name in naver_ids:
|
||||
continue
|
||||
try:
|
||||
found = await naver_place_lookup.find_place_id(c.name, c.road_address or c.address)
|
||||
except Exception as ex: # noqa: BLE001 — 지도 URL 직접 입력으로 이어진다
|
||||
LOG.w(f"[verify] 겨냥 조회 실패({c.name}): {type(ex).__name__}: {ex}")
|
||||
break
|
||||
if found:
|
||||
naver_ids[c.name] = found
|
||||
|
||||
res.candidates = [
|
||||
PlaceCandidate(
|
||||
external_place_id=getattr(c, "kakao_place_id", None) or getattr(c, "naver_place_id", None),
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
(channel 링크만 스냅샷에 없어서 호출측이 읽어 넘긴다.)
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import os
|
||||
import unicodedata
|
||||
from datetime import datetime, timezone
|
||||
@ -45,7 +46,13 @@ DEFAULT_PAYLOAD_DIR = "/app/out/payloads"
|
||||
# 검색엔진에는 열리지도 않는 주소가 등록된다(조용히 틀린다 — 아무도 눈치채지 못한다).
|
||||
# 프론트도 같은 이유로 VITE_PUBLISH_HOST 를 쓴다. 두 값은 **같아야 한다**.
|
||||
SITE_HOST_ENV = "SITE_PUBLIC_HOST"
|
||||
DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "web4ai.o2osolution.ai"
|
||||
# 기본값은 localhost. 운영 도메인을 기본으로 두면 설정을 빠뜨린 환경이 조용히 운영 주소로
|
||||
# canonical·sitemap 을 굽는다 — 틀렸다는 걸 아무도 모른다.
|
||||
DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "localhost"
|
||||
|
||||
def _scheme(host: str) -> str:
|
||||
"""localhost 는 http 다. shared/lib/slug.ts publishUrl 과 같은 규칙."""
|
||||
return "http" if re.match(r"^(localhost|127\.0\.0\.1)(:\d+)?$", host) else "https"
|
||||
|
||||
# 링크 제목이 비었을 때 채우는 채널 이름. 없는 채널명을 지어내지 않기 위한 고정 표다.
|
||||
_CHANNEL_TITLE = {
|
||||
@ -550,7 +557,7 @@ def _publish_target(site, place_id: str, name: str) -> dict:
|
||||
name_slug = slugify(name)
|
||||
slug = f"{name_slug}-{place_id[:8]}" if name_slug else f"place-{place_id[:8]}"
|
||||
|
||||
return {"origin": f"https://{DEFAULT_HOST}", "basePath": f"/s/{slug}", "slug": slug}
|
||||
return {"origin": f"{_scheme(DEFAULT_HOST)}://{DEFAULT_HOST}", "basePath": f"/s/{slug}", "slug": slug}
|
||||
|
||||
|
||||
def publish_slug(place, site) -> str:
|
||||
@ -565,7 +572,7 @@ def publish_slug(place, site) -> str:
|
||||
def publish_origin() -> str:
|
||||
"""발행본이 사는 오리진. 썸네일 URL 도 여기서 나온다 —
|
||||
호스트를 새 env 로 또 두면 canonical 과 갈릴 수 있다(CLAUDE.md '발행 호스트는 두 곳')."""
|
||||
return f"https://{DEFAULT_HOST}"
|
||||
return f"{_scheme(DEFAULT_HOST)}://{DEFAULT_HOST}"
|
||||
|
||||
|
||||
def primary_media(snapshot: dict) -> dict | None:
|
||||
|
||||
@ -5,7 +5,7 @@ VITE_API_BASE_URL=http://localhost:9800
|
||||
# 커스텀 도메인이 붙기 전까지 <slug>.<이 값> 형태로 나간다.
|
||||
# ★ 로컬에서 compose 없이 띄울 때만 쓴다. compose 로 띄우면 루트 .env 의 SITE_PUBLIC_HOST 가
|
||||
# 주입돼 이 값을 덮는다 — 발행 호스트의 단일 출처는 루트 .env 다.
|
||||
VITE_PUBLISH_HOST=web4ai.o2osolution.ai
|
||||
VITE_PUBLISH_HOST=localhost
|
||||
|
||||
# 발행 사이트 렌더러 개발 서버. 에디터 상단 "발행본 사이트 열기" 가 이 주소를 연다.
|
||||
VITE_SITE_PREVIEW_URL=http://localhost:3000
|
||||
|
||||
@ -21,4 +21,6 @@ export interface PlaceSearchItem {
|
||||
road_address?: PlaceSearchItemRoadAddress;
|
||||
category_name?: PlaceSearchItemCategoryName;
|
||||
category?: PlaceSearchItemCategory;
|
||||
/** @nullable */
|
||||
naver_place_url?: string | null;
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {useState} from 'react';
|
||||
import {Link2, Loader2, MapPin, Plus} from 'lucide-react';
|
||||
import {ExternalLink, Link2, Loader2, MapPin, Search} from 'lucide-react';
|
||||
import type {LinkChannel as LinkChannelCode} from '@/api';
|
||||
import {LinkChannel} from '@o2o/shared';
|
||||
import {Button} from '@/components/ui/button';
|
||||
@ -45,15 +45,18 @@ const HOW_TO = [
|
||||
'네이버 지도(또는 네이버 검색)에서 내 가게를 찾습니다',
|
||||
'가게 이름 옆 [공유] 를 누릅니다',
|
||||
'[URL 복사] 를 눌러 주소를 복사합니다',
|
||||
'아래 칸에 붙여넣고 [추가] 를 누릅니다',
|
||||
'아래 칸에 붙여넣고 [이 주소로 가져오기] 를 누릅니다',
|
||||
];
|
||||
|
||||
export function ChannelUrlInput({
|
||||
onAdd,
|
||||
isBusy,
|
||||
searchQuery,
|
||||
}: {
|
||||
onAdd: (url: string, channel: LinkChannelCode, title: string) => Promise<void>;
|
||||
isBusy?: boolean;
|
||||
/** 이 상호로 네이버 지도를 바로 연다 — 사장님이 새 탭에서 직접 검색어를 치지 않게. */
|
||||
searchQuery?: string;
|
||||
}) {
|
||||
const [url, setUrl] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -88,6 +91,20 @@ export function ChannelUrlInput({
|
||||
까지 그대로 가져옵니다.
|
||||
</p>
|
||||
|
||||
{/* ★ 여기에 바로가기가 없으면 사장님은 새 탭을 열고 상호를 다시 타이핑해야 한다.
|
||||
"찾아 보세요" 라고만 하고 갈 곳을 안 주면 그 자리에서 멈춘다. */}
|
||||
{searchQuery?.trim() && (
|
||||
<a
|
||||
href={`https://map.naver.com/p/search/${encodeURIComponent(searchQuery.trim())}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-primary/40 bg-background px-3 py-2.5 text-xs font-semibold transition-colors hover:bg-accent"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
<span>네이버 지도에서 '{searchQuery.trim()}' 열기</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<ol className="mt-3 space-y-1.5">
|
||||
{HOW_TO.map((step, index) => (
|
||||
<li key={step} className="flex items-start gap-2 text-[11px] leading-relaxed">
|
||||
@ -112,9 +129,11 @@ export function ChannelUrlInput({
|
||||
placeholder="https://map.naver.com/p/entry/place/1273971279"
|
||||
disabled={isBusy}
|
||||
/>
|
||||
{/* ★ '추가' 가 아니다. 이 버튼은 등록에서 끝나지 않고 **그 주소에서 정보를 가져온다.**
|
||||
'추가' 라고 쓰면 사장님은 목록에 한 줄 늘기를 기다리다가 아무 변화가 없어 실패로 읽는다. */}
|
||||
<Button size="sm" variant="primary" onClick={() => void submit()} disabled={isBusy || !url.trim()}>
|
||||
{isBusy ? <Loader2 className="animate-spin" /> : <Plus />}
|
||||
<span>추가</span>
|
||||
{isBusy ? <Loader2 className="animate-spin" /> : <Search />}
|
||||
<span>{isBusy ? '가져오는 중' : '이 주소로 가져오기'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@ -59,6 +59,15 @@ export function Step2PlaceSearch() {
|
||||
const search = usePlaceSearch(confirmedIdentity?.placeId ?? null);
|
||||
const [, goToStep] = useWizardStep();
|
||||
const [placeUrl, setPlaceUrl] = useState('');
|
||||
/** ★ 후보가 있으면 붙여넣기 칸을 접는다. 펼쳐 두면 "자동으로 못 찾았다"는 신호로 읽힌다 —
|
||||
* 이 시점에는 자동 발견을 시도조차 안 했다(수집 직전에 서버가 한다). 목록에 내 가게가
|
||||
* 없을 때만 여는 탈출구다. */
|
||||
const [urlBoxOpen, setUrlBoxOpen] = useState(false);
|
||||
/** 고른 후보가 들고 온 네이버 플레이스 주소(검색 단계에서 서버가 찾아 준 값). */
|
||||
const naverUrlRef = useRef<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';
|
||||
|
||||
@ -81,7 +90,10 @@ export function Step2PlaceSearch() {
|
||||
const identity = await search.confirmPick(pick, nextIndustry);
|
||||
setPendingPick(null);
|
||||
if (!identity) return; // 실패 사유는 search.pickError 가 화면에 남긴다
|
||||
confirmIdentity(identity);
|
||||
justConfirmed.current = true;
|
||||
confirmIdentity(
|
||||
naverUrlRef.current ? {...identity, naverPlaceUrl: naverUrlRef.current} : identity,
|
||||
);
|
||||
advance(identity.placeId);
|
||||
};
|
||||
|
||||
@ -112,6 +124,9 @@ export function Step2PlaceSearch() {
|
||||
name: item.name ?? '',
|
||||
address: item.road_address ?? '',
|
||||
};
|
||||
// ★ 검색 단계에서 서버가 이미 찾아 둔 플레이스 주소를 그대로 물고 간다.
|
||||
// 이게 있으면 수집 직전에 다시 찾을 필요가 없고, 사장님에게 지도 주소를 묻지도 않는다.
|
||||
naverUrlRef.current = item.naver_place_url ?? null;
|
||||
const guessed = item.category != null ? (CATEGORY_TO_INDUSTRY[item.category] ?? null) : null;
|
||||
if (!guessed) {
|
||||
setPendingPick(pick);
|
||||
@ -138,8 +153,9 @@ export function Step2PlaceSearch() {
|
||||
const pickByUrl = async () => {
|
||||
const url = placeUrl.trim();
|
||||
if (!url) return;
|
||||
const identity = await search.confirmByUrl(url, industry);
|
||||
const identity = await search.confirmByUrl(url);
|
||||
if (!identity) return;
|
||||
justConfirmed.current = true;
|
||||
confirmIdentity(identity);
|
||||
advance(identity.placeId);
|
||||
};
|
||||
@ -163,6 +179,7 @@ export function Step2PlaceSearch() {
|
||||
};
|
||||
|
||||
// ── 화면 고르기. 위에서부터 먼저 맞는 것 하나만 그린다 ──────────────
|
||||
//
|
||||
const stage = confirmedIdentity
|
||||
? 'confirmed'
|
||||
: search.phase === 'searching'
|
||||
@ -173,7 +190,32 @@ export function Step2PlaceSearch() {
|
||||
? 'picking'
|
||||
: 'input';
|
||||
|
||||
/**
|
||||
* 2단계에서 **뒤로 오면 처음(상호 입력)으로 되돌린다.**
|
||||
*
|
||||
* ★ 왜 확정 화면을 안 띄우나: 뒤로가기는 "다시 고르겠다" 는 뜻인데, 확정 화면만 뜨면
|
||||
* 다른 가게로 바꾸러 온 사장님이 목록을 못 찾는다. 후보 목록을 되살리는 방법도 있지만
|
||||
* 공개 검색이 유료 외부 API 라 뒤로 올 때마다 다시 부르게 된다.
|
||||
* ★ 이 화면을 **정상적으로 지나간** 경우(확정 직후 2단계로 넘어감)에는 돌지 않는다 —
|
||||
* `justConfirmed` 가 그 한 번을 막는다. 아니면 확정하자마자 스스로 지워 버린다.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!confirmedIdentity) return;
|
||||
if (justConfirmed.current) {
|
||||
justConfirmed.current = false;
|
||||
return;
|
||||
}
|
||||
clearIdentity();
|
||||
search.reset();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 들어올 때 한 번만 판단한다
|
||||
}, []);
|
||||
|
||||
const searchQuery = [storeName, location].filter(Boolean).join(' ');
|
||||
/** 뒤로 와서 목록을 다시 볼 때, 이미 고른 후보를 알아보게 한다. */
|
||||
const isPicked = (item: PlaceSearchItem) =>
|
||||
Boolean(confirmedIdentity) &&
|
||||
item.name === confirmedIdentity?.name &&
|
||||
(item.road_address ?? '') === (confirmedIdentity?.address ?? '');
|
||||
|
||||
return (
|
||||
<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',
|
||||
)}
|
||||
>
|
||||
{search.items.length > 0
|
||||
? '아래 목록에서 사장님 가게를 골라 주세요.'
|
||||
: '이 이름으로는 찾지 못했습니다. 아래에서 네이버 지도 주소로 찾아 주세요.'}
|
||||
{search.items.length === 0
|
||||
? '이 이름으로는 찾지 못했습니다. 아래에서 네이버 지도 주소로 찾아 주세요.'
|
||||
: foundCount > 0
|
||||
? `${foundCount}곳은 네이버 플레이스까지 찾았습니다 — 고르시면 영업시간·전화·사진을 그대로 가져옵니다.`
|
||||
: '아래 목록에서 사장님 가게를 골라 주세요.'}
|
||||
</p>
|
||||
|
||||
{search.pickError && (
|
||||
@ -281,14 +325,33 @@ export function Step2PlaceSearch() {
|
||||
)}
|
||||
{/* 고르기 전에 어떤 업종으로 시작하는지 먼저 보여준다 — 고른 뒤에 알면 늦다. */}
|
||||
<IndustryBadge category={item.category} />
|
||||
{/* ★ 플레이스를 찾았는지 **여기서** 보여준다. 데이터만 싣고 표시를 안 하면
|
||||
사장님은 자동으로 찾았는지 알 길이 없어 지도 주소를 또 찾으러 간다. */}
|
||||
{item.naver_place_url && (
|
||||
<Badge variant="success" className="gap-1 text-[10px]">
|
||||
<Check className="size-3" />
|
||||
네이버 플레이스 찾음
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 flex items-start gap-1 text-xs text-muted-foreground">
|
||||
<MapPin className="mt-px size-3 shrink-0" />
|
||||
<span>{item.road_address ?? '주소 없음'}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-lg bg-primary px-3 py-1.5 text-[11px] font-bold text-primary-foreground">
|
||||
{search.isConfirming ? '확정 중...' : '이 가게예요'}
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-lg px-3 py-1.5 text-[11px] font-bold',
|
||||
isPicked(item)
|
||||
? 'bg-success text-success-foreground'
|
||||
: 'bg-primary text-primary-foreground',
|
||||
)}
|
||||
>
|
||||
{search.isConfirming
|
||||
? '확정 중...'
|
||||
: isPicked(item)
|
||||
? '고른 가게'
|
||||
: '이 가게예요'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
@ -303,6 +366,15 @@ export function Step2PlaceSearch() {
|
||||
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
|
||||
value={placeUrl}
|
||||
onChange={setPlaceUrl}
|
||||
@ -311,6 +383,7 @@ export function Step2PlaceSearch() {
|
||||
compact={search.items.length > 0 && !search.pickError}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" size="sm" className="w-full" onClick={backToInput}>
|
||||
<Search />
|
||||
|
||||
@ -6,8 +6,13 @@ import {Input} from '@/components/ui/input';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import {INDUSTRY_CONFIGS} from '@/data/industryData';
|
||||
import {cn} from '@/lib/utils';
|
||||
import type {LinkChannel as LinkChannelCode} from '@/api';
|
||||
import {getAccessToken} from '@/api';
|
||||
import {SignInForm} from '@/features/auth/SignInForm';
|
||||
import {useAuthStore} from '@/stores/auth';
|
||||
import {useBuilderStore, useUnverifiedFields} from '@/stores/builder';
|
||||
import {ChannelConfirmPanel} from './ChannelConfirmPanel';
|
||||
import {ensureServerPlace} from './ensureServerPlace';
|
||||
import {ChannelUrlInput} from './ChannelUrlInput';
|
||||
import {useCollectFlow} from './useCollectFlow';
|
||||
import {useWizardStep} from './wizardUrl';
|
||||
@ -40,9 +45,67 @@ export function Step3DataReview() {
|
||||
const photos = useBuilderStore((s) => s.photos);
|
||||
const [, goToStep] = useWizardStep();
|
||||
|
||||
const confirmIdentity = useBuilderStore((s) => s.confirmIdentity);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const unverified = useUnverifiedFields();
|
||||
const placeId = confirmedIdentity?.placeId ?? null;
|
||||
const collect = useCollectFlow(placeId);
|
||||
/** 로그인 관문을 이 화면에서 연다 — 위저드에서 서버를 처음 부르는 지점이 여기다. */
|
||||
const [signingIn, setSigningIn] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
/** 찾아 둔 주소가 있어도 사장님이 직접 넣겠다고 하면 펼친다. */
|
||||
const [urlBoxOpen, setUrlBoxOpen] = useState(false);
|
||||
|
||||
/**
|
||||
* 수집 시작 — **위저드에서 서버를 처음 부르는 지점.**
|
||||
*
|
||||
* ★ 1단계의 확정은 화면에만 남는다(usePlaceSearch). 그래서 여기서 세 가지를 순서대로 한다:
|
||||
* 로그인 → 사업장 생성·검증(ensureServerPlace) → 수집. 로그인을 앞 단계로 올리면
|
||||
* 아무것도 못 본 사장님에게 가입을 요구하게 된다(docs/PRODUCT.md "로그인 최소").
|
||||
*/
|
||||
/** 로그인 모달을 띄우기 직전의 신원. 로그인이 끝나면 그대로 이어 돌린다. */
|
||||
const pendingAfterSignIn = useRef<typeof confirmedIdentity>(null);
|
||||
|
||||
const beginCollect = async (identity = confirmedIdentity) => {
|
||||
if (!identity) return;
|
||||
if (!user && !getAccessToken()) {
|
||||
// ★ 로그인 뒤에 이어서 돌 값을 들고 있는다. 안 들고 있으면 로그인만 하고 제자리로
|
||||
// 돌아와, 사장님이 같은 버튼을 두 번 눌러야 한다(실측 2026-09-03).
|
||||
pendingAfterSignIn.current = identity;
|
||||
setSigningIn(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!identity.placeId) {
|
||||
setIsUploading(true);
|
||||
const uploaded = await ensureServerPlace(identity, industry);
|
||||
setIsUploading(false);
|
||||
if (!uploaded?.placeId) return;
|
||||
confirmIdentity(uploaded);
|
||||
return; // placeId 가 스토어에 박히면 아래 자동 수집 effect 가 이어받는다
|
||||
}
|
||||
void collect.crawl();
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 사장님이 붙여넣은 주소를 받는다.
|
||||
*
|
||||
* ★ 서버에 사업장이 없으면 `collect.addLink` 는 **조용히 아무것도 안 한다**(placeId 가
|
||||
* 없어서 첫 줄에서 return). 그러면 입력칸만 비워져 사장님은 실패로 읽는다(실측).
|
||||
* 그래서 그 경우엔 신원에 주소를 싣고 같은 흐름(로그인 → 생성 → 검증 → 수집)으로 보낸다.
|
||||
*/
|
||||
const addUrl = async (url: string, channel: LinkChannelCode, title: string) => {
|
||||
if (confirmedIdentity?.placeId) {
|
||||
await collect.addLink(url, channel, title);
|
||||
return;
|
||||
}
|
||||
if (!confirmedIdentity) return;
|
||||
const next = {...confirmedIdentity, naverPlaceUrl: url};
|
||||
confirmIdentity(next);
|
||||
await beginCollect(next);
|
||||
};
|
||||
const collectPhase = collect.phase;
|
||||
const startAutomaticCollect = collect.crawl;
|
||||
const autoCollectStarted = useRef(false);
|
||||
@ -75,6 +138,15 @@ export function Step3DataReview() {
|
||||
|
||||
const config = INDUSTRY_CONFIGS[industry];
|
||||
const canCollect = confirmedIdentity?.origin === 'external';
|
||||
/** ★ 버튼을 언제 열어 두나.
|
||||
* `confirmedCount` 는 **서버에 사업장이 있어야** 나오는 값이다. 그것만 보면 로그인 전에는
|
||||
* 버튼이 영원히 잠겨, 자동 발견이 돌 기회조차 없다(실측 2026-09-03: 후보를 골라도
|
||||
* "주소를 먼저 붙여넣어 주세요" 로 잠긴 채 멈춘다).
|
||||
* 아직 서버에 안 올렸으면(=placeId 없음) 자동 발견을 시도해 볼 여지가 있으므로 열어 둔다.
|
||||
* 올렸는데도 채널이 0이면 그때는 자동 발견이 실패한 것이라 붙여넣기를 요구한다. */
|
||||
const pendingUrl = confirmedIdentity?.naverPlaceUrl;
|
||||
const autoLookupPending = canCollect && !placeId;
|
||||
const hasSource = collect.confirmedCount > 0 || Boolean(pendingUrl) || autoLookupPending;
|
||||
/** 아직 실제 수집이 붙지 않은 줄들 — 업종 예시다. 그렇게 부른다. */
|
||||
const isSample = !confirmedIdentity || confirmedIdentity.origin === 'owner';
|
||||
|
||||
@ -89,13 +161,52 @@ export function Step3DataReview() {
|
||||
? 'review'
|
||||
: 'ready';
|
||||
|
||||
// ★ /login 으로 튕기지 않는다. 여기까지 쌓은 위저드 상태는 주소창에 없어서 돌아올 길이 없다.
|
||||
if (signingIn) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center bg-muted/30 px-4 py-10">
|
||||
<SignInForm
|
||||
submitLabel="로그인하고 정보 가져오기"
|
||||
onSignedIn={() => {
|
||||
setSigningIn(false);
|
||||
const resume = pendingAfterSignIn.current;
|
||||
pendingAfterSignIn.current = null;
|
||||
if (resume) void beginCollect(resume);
|
||||
}}
|
||||
header={
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="text-sm font-bold">정보를 가져오려면 로그인해 주세요</h1>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
고르신 가게는 그대로 있습니다. 수집한 정보를 사장님 계정에 담아 두기 위해
|
||||
여기서 한 번만 받습니다.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSigningIn(false)}
|
||||
className="w-full text-center text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
나중에 하기
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col justify-between bg-muted/30">
|
||||
<WizardStage
|
||||
current={2}
|
||||
wide={stage === 'review' || stage === 'channels'}
|
||||
title={STAGE_TITLE[stage]}
|
||||
description={STAGE_DESCRIPTION[stage]}
|
||||
title={stage === 'ready' && pendingUrl ? '네이버 플레이스를 찾았습니다' : STAGE_TITLE[stage]}
|
||||
description={
|
||||
stage === 'ready' && pendingUrl
|
||||
? '이 가게에서 영업시간·전화·편의시설과 사진을 그대로 가져옵니다.'
|
||||
: STAGE_DESCRIPTION[stage]
|
||||
}
|
||||
>
|
||||
{stage === 'ready' && (
|
||||
<div className="space-y-3">
|
||||
@ -113,22 +224,52 @@ export function Step3DataReview() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ★ 1순위 경로. URL 만 있으면 100% 되므로 화면에서도 맨 위에 둔다. */}
|
||||
{canCollect && <ChannelUrlInput onAdd={collect.addLink} isBusy={collect.isAdding} />}
|
||||
{/* ★ 이미 찾아 둔 주소가 있으면 붙여넣기 안내를 접는다. 펼쳐 두면 사장님은
|
||||
"또 못 찾았구나" 로 읽고 지도를 열러 간다 — 찾아 놓고 그러는 건 최악이다. */}
|
||||
{canCollect && pendingUrl && !urlBoxOpen ? (
|
||||
<div className="rounded-2xl border border-success/40 bg-success/5 p-4">
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold">
|
||||
<Check className="size-3.5 text-success" />
|
||||
네이버 플레이스를 찾았습니다
|
||||
</p>
|
||||
<p className="mt-1 truncate text-[11px] text-muted-foreground">{pendingUrl}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUrlBoxOpen(true)}
|
||||
className="mt-2 cursor-pointer text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
이 가게가 아닌가요? — 주소 직접 넣기
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
canCollect && (
|
||||
<ChannelUrlInput
|
||||
onAdd={addUrl}
|
||||
isBusy={collect.isAdding || isUploading}
|
||||
searchQuery={[confirmedIdentity?.name, confirmedIdentity?.address]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{canCollect && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={collect.crawl}
|
||||
disabled={collect.confirmedCount === 0}
|
||||
onClick={() => void beginCollect()}
|
||||
disabled={isUploading || !hasSource}
|
||||
>
|
||||
<Search />
|
||||
<span>
|
||||
{collect.confirmedCount === 0
|
||||
{!hasSource
|
||||
? '주소를 먼저 붙여넣어 주세요'
|
||||
: `등록한 ${collect.confirmedCount}개 주소에서 정보 가져오기`}
|
||||
: collect.confirmedCount > 0
|
||||
? `등록한 ${collect.confirmedCount}개 주소에서 정보 가져오기`
|
||||
: isUploading
|
||||
? '내 가게를 찾는 중입니다...'
|
||||
: '내 가게 정보 자동으로 가져오기'}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
@ -217,20 +358,26 @@ export function Step3DataReview() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChannelUrlInput onAdd={collect.addLink} isBusy={collect.isAdding} />
|
||||
<ChannelUrlInput
|
||||
onAdd={collect.addLink}
|
||||
isBusy={collect.isAdding}
|
||||
searchQuery={[confirmedIdentity?.name, confirmedIdentity?.address].filter(Boolean).join(' ')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={collect.crawl}
|
||||
disabled={collect.confirmedCount === 0}
|
||||
onClick={() => void beginCollect()}
|
||||
disabled={isUploading || !hasSource}
|
||||
>
|
||||
<Search />
|
||||
<span>
|
||||
{collect.confirmedCount === 0
|
||||
{!hasSource
|
||||
? '확정한 채널이 없습니다'
|
||||
: `확정한 ${collect.confirmedCount}개 채널에서 수집하기`}
|
||||
: collect.confirmedCount > 0
|
||||
? `확정한 ${collect.confirmedCount}개 채널에서 수집하기`
|
||||
: '네이버 플레이스에서 정보 가져오기'}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -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 {
|
||||
createPlace,
|
||||
getAccessToken,
|
||||
PlaceCategory as PlaceCategoryEnum,
|
||||
searchPlacesPublic,
|
||||
updatePlace,
|
||||
verifyCandidates,
|
||||
verifyPlaceByUrl,
|
||||
} from '@/api';
|
||||
import type {ConfirmedIdentity} from '@/stores/builder';
|
||||
import {ensureAutoSession} from '@/lib/autoSession';
|
||||
import {describeError} from '@/lib/errorMessages';
|
||||
import {notify, notifyApiError} from '@/lib/notify';
|
||||
|
||||
@ -51,6 +47,8 @@ export interface PlaceSearchState {
|
||||
pickError: string;
|
||||
}
|
||||
|
||||
/** 네이버 플레이스가 확정되지 않았을 때의 유일한 안내. 두 갈래(토큰 없음·자동 발견 실패)가
|
||||
* 같은 문구를 써야 사장님이 볼 칸이 하나로 모인다. */
|
||||
const INITIAL: PlaceSearchState = {
|
||||
phase: 'idle',
|
||||
items: [],
|
||||
@ -175,43 +173,26 @@ export function usePlaceSearch(existingPlaceId: string | null = null) {
|
||||
* 네이버 플레이스 URL 로 확정한다.
|
||||
*
|
||||
* ★ 이 경로가 가장 확실하다. 상호 검색은 동명 업소·지점명 표기 차이에서 실패하고
|
||||
* (실측: '롯데호텔 서울'), 검색모델은 네이버 플레이스를 아예 못 찾는다.
|
||||
* 반면 사장님은 자기 가게 주소를 이미 안다 — 붙여넣는 순간 신원·채널이 동시에 확정된다.
|
||||
* 상호·주소는 서버가 그 URL 에서 읽어 온다(손으로 옮겨 적게 하면 오타가 남의 가게가 된다).
|
||||
* (실측: '롯데호텔 서울'), 자동 발견은 네이버가 검색 결과에서 플레이스 id 를 빼면서
|
||||
* 2026-09-01 부터 아예 안 된다. 반면 사장님은 자기 가게 주소를 이미 안다.
|
||||
*/
|
||||
const confirmByUrl = useCallback(
|
||||
async (url: string, industry: IndustryType): Promise<ConfirmedIdentity | null> => {
|
||||
setIsConfirming(true);
|
||||
try {
|
||||
const placeId = await ensurePlace(
|
||||
nameRef.current || '내 가게',
|
||||
INDUSTRY_TO_CATEGORY[industry],
|
||||
new AbortController().signal,
|
||||
);
|
||||
if (!placeId) return null;
|
||||
const res = await verifyPlaceByUrl(placeId, {url});
|
||||
const place = res.place;
|
||||
if (!place) {
|
||||
notify.error('가게 정보를 읽지 못했습니다.', res.msg ?? '주소를 다시 확인해 주세요.');
|
||||
return null;
|
||||
}
|
||||
async (url: string): Promise<ConfirmedIdentity | null> => {
|
||||
setState((prev) => ({...prev, pickError: ''}));
|
||||
// ★ 여기서 서버를 부르지 않는다. 사업장 생성·검증은 토큰을 요구하는데, 로그인은
|
||||
// **수집 직전 한 번만** 묻기로 했다. 주소만 신원에 실어 두고, 수집을 누를 때
|
||||
// 로그인 → 사업장 생성 → 이 주소로 검증 → 수집을 한 번에 보낸다.
|
||||
// 상호·주소는 그때 서버가 이 주소에서 읽어 덮어쓴다.
|
||||
return {
|
||||
placeId,
|
||||
name: place.name ?? '',
|
||||
address: place.road_address ?? place.address ?? '',
|
||||
phone: place.phone ?? undefined,
|
||||
placeId: placeIdRef.current,
|
||||
name: nameRef.current || '내 가게',
|
||||
address: '',
|
||||
origin: 'external',
|
||||
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(
|
||||
async (
|
||||
pick: {name: string; address: string},
|
||||
industry: IndustryType,
|
||||
_industry: IndustryType,
|
||||
): Promise<ConfirmedIdentity | null> => {
|
||||
nameRef.current = pick.name.trim();
|
||||
setState((prev) => ({...prev, pickError: ''}));
|
||||
|
||||
// ★ 자동 로그인이 켜져 있으면 끝날 때까지 기다린다. 이걸 안 기다리면 페이지를 열자마자
|
||||
// 누른 확정이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
|
||||
await ensureAutoSession();
|
||||
if (!getAccessToken()) return confirmManual(pick.name, pick.address, '지도 검색');
|
||||
|
||||
setIsConfirming(true);
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const placeId = await ensurePlace(
|
||||
pick.name.trim(),
|
||||
INDUSTRY_TO_CATEGORY[industry],
|
||||
controller.signal,
|
||||
);
|
||||
if (!placeId) throw new Error('사업장을 만들지 못했습니다.');
|
||||
|
||||
// 상호만으로는 동명 업소가 섞인다 — 사장님이 고른 그 주소까지 붙여 좁힌다.
|
||||
const query = [pick.name.trim(), pick.address.trim()].filter(Boolean).join(' ');
|
||||
const res = await verifyCandidates(placeId, {query}, undefined, controller.signal);
|
||||
const candidates = res.candidates ?? [];
|
||||
const hit =
|
||||
candidates.find(
|
||||
(c) => c.naver_place_url && normalizeName(c.name) === normalizeName(pick.name),
|
||||
) ?? candidates.find((c) => c.naver_place_url);
|
||||
|
||||
if (!hit?.naver_place_url) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
pickError:
|
||||
'이 가게의 네이버 플레이스를 자동으로 찾지 못했습니다. 아래에서 지도 주소를 붙여넣어 주세요.',
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
return await confirmByUrl(hit.naver_place_url, industry);
|
||||
} catch (error) {
|
||||
notifyApiError(error, '이 가게로 확정하지 못했습니다.');
|
||||
return null;
|
||||
} finally {
|
||||
setIsConfirming(false);
|
||||
}
|
||||
// ★ 서버를 부르지 않는다(confirmByUrl 주석). 고른 후보의 상호·주소로 신원만 세우고,
|
||||
// 사업장 생성·검증은 수집 직전 로그인 뒤에 한 번에 한다.
|
||||
return {
|
||||
placeId: placeIdRef.current,
|
||||
name: pick.name.trim(),
|
||||
address: pick.address.trim(),
|
||||
origin: 'external',
|
||||
sourceLabel: '지도 검색',
|
||||
};
|
||||
},
|
||||
[confirmByUrl, confirmManual, ensurePlace],
|
||||
[],
|
||||
);
|
||||
|
||||
return {...state, isConfirming, searchPublic, confirmPick, confirmByUrl, confirmManual, reset};
|
||||
|
||||
@ -22,9 +22,10 @@ export function LoginPage({selfServe = true}: {selfServe?: boolean}) {
|
||||
const location = useLocation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
// 개발 편의로 기본값을 채워 둔다. 운영에 열기 전에 반드시 빈 문자열로 되돌린다.
|
||||
const [id, setId] = useState('admin');
|
||||
const [password, setPassword] = useState('1234');
|
||||
// ★ 비워 둔다. 채워 두면 번들에 그대로 구워져 나가고(실측 2026-09-03: 배포본에
|
||||
// admin/1234 가 박혀 있었다), 아이디만 고쳐 넣은 사람이 남의 비밀번호로 로그인을 시도한다.
|
||||
const [id, setId] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록)
|
||||
|
||||
@ -79,6 +79,9 @@ export interface ConfirmedIdentity {
|
||||
externalPlaceId?: string;
|
||||
/** 후보가 들고 있던 공식 홈페이지. 확정 시 공식 채널로 등록된다. */
|
||||
placeUrl?: string;
|
||||
/** 사장님이 붙여넣은 네이버 플레이스 주소. **아직 서버에 안 보냈다** —
|
||||
* 수집 직전 로그인한 뒤에 이 값으로 사업장을 검증한다(usePlaceSearch.confirmByUrl). */
|
||||
naverPlaceUrl?: string;
|
||||
}
|
||||
|
||||
export interface ApplyPlaceOptions {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user