diff --git a/.env.example b/.env.example index dab8ed8..950d8b1 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/docker-compose.yml b/docker-compose.yml index f9a02a1..59f677a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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:-} diff --git a/solution/backend/common/utils/ttl_cache.py b/solution/backend/common/utils/ttl_cache.py new file mode 100644 index 0000000..26fc4b9 --- /dev/null +++ b/solution/backend/common/utils/ttl_cache.py @@ -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() diff --git a/solution/backend/router/v1/place/protocol.py b/solution/backend/router/v1/place/protocol.py index e9edf4e..2fb75ea 100644 --- a/solution/backend/router/v1/place/protocol.py +++ b/solution/backend/router/v1/place/protocol.py @@ -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): diff --git a/solution/backend/services/external/naver_place_lookup.py b/solution/backend/services/external/naver_place_lookup.py index b8a9db4..1efe49a 100644 --- a/solution/backend/services/external/naver_place_lookup.py +++ b/solution/backend/services/external/naver_place_lookup.py @@ -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 diff --git a/solution/backend/services/place_service.py b/solution/backend/services/place_service.py index 385fde5..ef78d95 100644 --- a/solution/backend/services/place_service.py +++ b/solution/backend/services/place_service.py @@ -50,6 +50,12 @@ from services.external.naver import region_key from services.job_service import enqueue_job +# 겨냥 조회를 몇 건까지 할지. 후보 전부(5건)를 부르면 통합검색이 429 를 준다. +_TARGETED_LOOKUP_LIMIT = 2 +# 공개 검색 캐시 수명. 가게 정보가 이 안에 바뀔 일은 없다. +_PUBLIC_SEARCH_CACHE_SEC = 600 + + class PlaceService: """사업장 등록·조회·동일 업소 검증. @@ -605,9 +611,10 @@ class PlaceService: ★ DB 를 읽지도 쓰지도 않는다. 나가는 값은 외부 장소 DB 가 공개적으로 주는 것뿐이다. """ from common.enums import ExternalPlaceSource - from common.utils import rate_limit + from common.utils import rate_limit, ttl_cache from services.external import kakao as kakao_client from services.external import naver as naver_client + from services.external import naver_place_lookup from services.place_category import guess_category res = Res_PlaceSearch() @@ -616,6 +623,14 @@ class PlaceService: res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) return res + # 같은 검색어는 캐시로 받는다. 사장님이 상호를 고쳐 가며 대여섯 번 치는 동안 + # 네이버를 매번 세 번씩 긁지 않게 한다. + cache_key = f"place-search:{q}" + cached = ttl_cache.get(cache_key) + if cached is not None: + res.source, res.items = cached + return res + # ★ 인증이 없는데 유료 외부 API 를 부른다 — 방어가 0 이면 새로고침만으로 요금이 나간다. # 프로세스 메모리 기반이라 완전하지 않다(common/utils/rate_limit.py 주석). if not rate_limit.allow(f"place-search:{client_ip}", _PUBLIC_SEARCH_PER_MIN, 60.0): @@ -644,6 +659,23 @@ class PlaceService: res.result.SetResult(ErrorType.LOCAL_FETCH_FAILED) return res + # ★ 여기서 네이버 플레이스 주소까지 찾아 실어 보낸다. + # 전에는 확정 경로(verify/candidates)에서만 찾았는데, 그건 로그인 뒤라 사장님이 + # 후보를 고를 때는 "이 가게의 플레이스가 있는지" 를 알 수 없었다. 화면은 그걸 + # "자동으로 못 찾았다" 로 읽고 지도 주소를 물었다 — 찾을 수 있는데도. + # 실패는 조용히 넘긴다(None). 유료 API 가 아니라 공개 페이지 조회다. + naver_ids: dict[str, str] = {} + try: + naver_ids = await naver_place_lookup.find_place_ids(q, [r.name for r in rows]) + for row in rows[:_TARGETED_LOOKUP_LIMIT]: + if row.name in naver_ids: + continue + found = await naver_place_lookup.find_place_id(row.name, row.road_address or row.address) + if found: + naver_ids[row.name] = found + except Exception as ex: # noqa: BLE001 — 지도 주소 붙여넣기로 이어진다 + LOG.w(f"[search] 플레이스 자동 발견 실패(후보는 유지): {type(ex).__name__}: {ex}") + res.items = [ PlaceSearchItem( name=row.name, @@ -651,11 +683,19 @@ class PlaceService: category_name=row.category_name, # 네이버는 그룹코드를 주지 않는다 — 그때는 분류 문자열만으로 추정한다. category=guess_category(row.category_name, getattr(row, "category_group_code", None)), + naver_place_url=( + naver_place_lookup.place_url(naver_ids[row.name]) if row.name in naver_ids else None + ), ) for row in rows ] if not res.items: + # ★ 빈 결과는 캐시하지 않는다. 네이버가 잠깐 막아서 0건이 나온 것을 굳히면 + # 사장님은 TTL 이 끝날 때까지 아무것도 못 한다. res.result.SetResult(ErrorType.PLACE_VERIFY_NO_CANDIDATE) + return res + + ttl_cache.put(cache_key, (res.source, res.items), _PUBLIC_SEARCH_CACHE_SEC) return res async def find_candidates(self, user_info: UserInfo, place_id: str, query: str | None = None) -> Res_VerifyCandidates: @@ -731,6 +771,22 @@ class PlaceService: except Exception as ex: # noqa: BLE001 — 지도 URL 직접 입력으로 이어진다 LOG.w(f"[verify] 네이버 플레이스 자동 발견 실패(후보는 유지): {type(ex).__name__}: {ex}") naver_ids = {} + # ★ 넓은 검색어 한 페이지에서 못 찾은 후보는 **그 후보만 겨냥해** 한 번 더 찾는다. + # 넓은 검색은 결과가 다른 지점으로 채워져 이름이 아예 안 실릴 때가 있다 — + # 실측(2026-09-03 '스타벅스 판교역점'): 페이지에 id 6개가 있는데 지역검색이 준 + # 5개 지점명은 하나도 그 근처에 없었다. 상호+지역으로 겨냥하면 4/4 로 찾는다. + # 호출은 상위 후보 몇 건으로 끊는다 — 5건을 다 부르면 429 를 받는다. + for c in rows[:_TARGETED_LOOKUP_LIMIT]: + if c.name in naver_ids: + continue + try: + found = await naver_place_lookup.find_place_id(c.name, c.road_address or c.address) + except Exception as ex: # noqa: BLE001 — 지도 URL 직접 입력으로 이어진다 + LOG.w(f"[verify] 겨냥 조회 실패({c.name}): {type(ex).__name__}: {ex}") + break + if found: + naver_ids[c.name] = found + res.candidates = [ PlaceCandidate( external_place_id=getattr(c, "kakao_place_id", None) or getattr(c, "naver_place_id", None), diff --git a/solution/backend/services/site_payload.py b/solution/backend/services/site_payload.py index ee3d827..1281900 100644 --- a/solution/backend/services/site_payload.py +++ b/solution/backend/services/site_payload.py @@ -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: diff --git a/solution/frontend/.env.example b/solution/frontend/.env.example index d4c1074..61952c3 100644 --- a/solution/frontend/.env.example +++ b/solution/frontend/.env.example @@ -5,7 +5,7 @@ VITE_API_BASE_URL=http://localhost:9800 # 커스텀 도메인이 붙기 전까지 .<이 값> 형태로 나간다. # ★ 로컬에서 compose 없이 띄울 때만 쓴다. compose 로 띄우면 루트 .env 의 SITE_PUBLIC_HOST 가 # 주입돼 이 값을 덮는다 — 발행 호스트의 단일 출처는 루트 .env 다. -VITE_PUBLISH_HOST=web4ai.o2osolution.ai +VITE_PUBLISH_HOST=localhost # 발행 사이트 렌더러 개발 서버. 에디터 상단 "발행본 사이트 열기" 가 이 주소를 연다. VITE_SITE_PREVIEW_URL=http://localhost:3000 diff --git a/solution/frontend/src/api/generated/model/placeSearchItem.ts b/solution/frontend/src/api/generated/model/placeSearchItem.ts index 1aacf27..92eac8d 100644 --- a/solution/frontend/src/api/generated/model/placeSearchItem.ts +++ b/solution/frontend/src/api/generated/model/placeSearchItem.ts @@ -21,4 +21,6 @@ export interface PlaceSearchItem { road_address?: PlaceSearchItemRoadAddress; category_name?: PlaceSearchItemCategoryName; category?: PlaceSearchItemCategory; + /** @nullable */ + naver_place_url?: string | null; } diff --git a/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx b/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx index 651aa90..53a38e1 100644 --- a/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx +++ b/solution/frontend/src/features/onboarding/ChannelUrlInput.tsx @@ -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; isBusy?: boolean; + /** 이 상호로 네이버 지도를 바로 연다 — 사장님이 새 탭에서 직접 검색어를 치지 않게. */ + searchQuery?: string; }) { const [url, setUrl] = useState(''); const [error, setError] = useState(null); @@ -88,6 +91,20 @@ export function ChannelUrlInput({ 까지 그대로 가져옵니다.

+ {/* ★ 여기에 바로가기가 없으면 사장님은 새 탭을 열고 상호를 다시 타이핑해야 한다. + "찾아 보세요" 라고만 하고 갈 곳을 안 주면 그 자리에서 멈춘다. */} + {searchQuery?.trim() && ( + + + 네이버 지도에서 '{searchQuery.trim()}' 열기 + + )} +
    {HOW_TO.map((step, index) => (
  1. @@ -112,9 +129,11 @@ export function ChannelUrlInput({ placeholder="https://map.naver.com/p/entry/place/1273971279" disabled={isBusy} /> + {/* ★ '추가' 가 아니다. 이 버튼은 등록에서 끝나지 않고 **그 주소에서 정보를 가져온다.** + '추가' 라고 쓰면 사장님은 목록에 한 줄 늘기를 기다리다가 아무 변화가 없어 실패로 읽는다. */} diff --git a/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx b/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx index 16b1947..e952c8f 100644 --- a/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx +++ b/solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx @@ -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(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 (
    @@ -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}곳은 네이버 플레이스까지 찾았습니다 — 고르시면 영업시간·전화·사진을 그대로 가져옵니다.` + : '아래 목록에서 사장님 가게를 골라 주세요.'}

    {search.pickError && ( @@ -281,14 +325,33 @@ export function Step2PlaceSearch() { )} {/* 고르기 전에 어떤 업종으로 시작하는지 먼저 보여준다 — 고른 뒤에 알면 늦다. */} + {/* ★ 플레이스를 찾았는지 **여기서** 보여준다. 데이터만 싣고 표시를 안 하면 + 사장님은 자동으로 찾았는지 알 길이 없어 지도 주소를 또 찾으러 간다. */} + {item.naver_place_url && ( + + + 네이버 플레이스 찾음 + + )}

    {item.road_address ?? '주소 없음'}

    - - {search.isConfirming ? '확정 중...' : '이 가게예요'} + + {search.isConfirming + ? '확정 중...' + : isPicked(item) + ? '고른 가게' + : '이 가게예요'} @@ -303,14 +366,24 @@ export function Step2PlaceSearch() { onChange={changeIndustry} /> - void pickByUrl()} - isBusy={search.isConfirming} - compact={search.items.length > 0 && !search.pickError} - searchQuery={searchQuery} - /> + {search.items.length > 0 && !urlBoxOpen && !search.pickError ? ( + + ) : ( + void pickByUrl()} + isBusy={search.isConfirming} + compact={search.items.length > 0 && !search.pickError} + searchQuery={searchQuery} + /> + )} + } + /> + + ); + } + return (
    {stage === 'ready' && (
    @@ -113,22 +224,52 @@ export function Step3DataReview() {

    )} - {/* ★ 1순위 경로. URL 만 있으면 100% 되므로 화면에서도 맨 위에 둔다. */} - {canCollect && } + {/* ★ 이미 찾아 둔 주소가 있으면 붙여넣기 안내를 접는다. 펼쳐 두면 사장님은 + "또 못 찾았구나" 로 읽고 지도를 열러 간다 — 찾아 놓고 그러는 건 최악이다. */} + {canCollect && pendingUrl && !urlBoxOpen ? ( +
    +

    + + 네이버 플레이스를 찾았습니다 +

    +

    {pendingUrl}

    + +
    + ) : ( + canCollect && ( + + ) + )} {canCollect && ( )} @@ -217,20 +358,26 @@ export function Step3DataReview() { />
    - +
    diff --git a/solution/frontend/src/features/onboarding/ensureServerPlace.ts b/solution/frontend/src/features/onboarding/ensureServerPlace.ts new file mode 100644 index 0000000..f0d52c9 --- /dev/null +++ b/solution/frontend/src/features/onboarding/ensureServerPlace.ts @@ -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 = { + stay: PlaceCategoryEnum.LODGING, + cafe: PlaceCategoryEnum.CAFE, + restaurant: PlaceCategoryEnum.RESTAURANT, + clinic: PlaceCategoryEnum.CLINIC, +}; + +/** + * 서버에 사업장이 있는 신원을 돌려준다. 이미 있으면 그대로 통과한다. + * 실패는 null — 호출측이 수집을 시작하지 않는다. + */ +export async function ensureServerPlace( + identity: ConfirmedIdentity, + industry: IndustryType, +): Promise { + 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; + } +} diff --git a/solution/frontend/src/features/onboarding/usePlaceSearch.ts b/solution/frontend/src/features/onboarding/usePlaceSearch.ts index 805180b..3dc4e68 100644 --- a/solution/frontend/src/features/onboarding/usePlaceSearch.ts +++ b/solution/frontend/src/features/onboarding/usePlaceSearch.ts @@ -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 => { - setIsConfirming(true); - try { - const placeId = await ensurePlace( - nameRef.current || '내 가게', - INDUSTRY_TO_CATEGORY[industry], - new AbortController().signal, - ); - if (!placeId) return null; - const res = await verifyPlaceByUrl(placeId, {url}); - const place = res.place; - if (!place) { - notify.error('가게 정보를 읽지 못했습니다.', res.msg ?? '주소를 다시 확인해 주세요.'); - return null; - } - return { - placeId, - name: place.name ?? '', - address: place.road_address ?? place.address ?? '', - phone: place.phone ?? undefined, - origin: 'external', - sourceLabel: '네이버 플레이스', - externalPlaceId: place.external_place_id ?? undefined, - }; - } catch (error) { - notifyApiError(error, '이 주소로 확정하지 못했습니다.'); - return null; - } finally { - setIsConfirming(false); - } + async (url: string): Promise => { + setState((prev) => ({...prev, pickError: ''})); + // ★ 여기서 서버를 부르지 않는다. 사업장 생성·검증은 토큰을 요구하는데, 로그인은 + // **수집 직전 한 번만** 묻기로 했다. 주소만 신원에 실어 두고, 수집을 누를 때 + // 로그인 → 사업장 생성 → 이 주소로 검증 → 수집을 한 번에 보낸다. + // 상호·주소는 그때 서버가 이 주소에서 읽어 덮어쓴다. + return { + placeId: placeIdRef.current, + name: nameRef.current || '내 가게', + address: '', + origin: 'external', + sourceLabel: '네이버 플레이스', + naverPlaceUrl: url.trim(), + }; }, - [ensurePlace], + [], ); /** @@ -243,52 +224,21 @@ export function usePlaceSearch(existingPlaceId: string | null = null) { const confirmPick = useCallback( async ( pick: {name: string; address: string}, - industry: IndustryType, + _industry: IndustryType, ): Promise => { 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}; diff --git a/solution/frontend/src/pages/LoginPage.tsx b/solution/frontend/src/pages/LoginPage.tsx index b4c69ce..863eb26 100644 --- a/solution/frontend/src/pages/LoginPage.tsx +++ b/solution/frontend/src/pages/LoginPage.tsx @@ -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); // ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록) diff --git a/solution/frontend/src/stores/builderTypes.ts b/solution/frontend/src/stores/builderTypes.ts index 225b0d8..0ef7738 100644 --- a/solution/frontend/src/stores/builderTypes.ts +++ b/solution/frontend/src/stores/builderTypes.ts @@ -79,6 +79,9 @@ export interface ConfirmedIdentity { externalPlaceId?: string; /** 후보가 들고 있던 공식 홈페이지. 확정 시 공식 채널로 등록된다. */ placeUrl?: string; + /** 사장님이 붙여넣은 네이버 플레이스 주소. **아직 서버에 안 보냈다** — + * 수집 직전 로그인한 뒤에 이 값으로 사업장을 검증한다(usePlaceSearch.confirmByUrl). */ + naverPlaceUrl?: string; } export interface ApplyPlaceOptions {