Merge remote-tracking branch 'origin/main'
# Conflicts: # solution/frontend/src/features/onboarding/Step2PlaceSearch.tsx
This commit is contained in:
commit
256de3f5e6
@ -44,7 +44,8 @@ GOOGLE_CLIENT_ID=
|
||||
|
||||
# ── solution/site 발행물 — solution/backend 도 같이 본다
|
||||
# canonical·og:url·sitemap·IndexNow 가 전부 SITE_PUBLIC_HOST 를 쓴다.
|
||||
SITE_PUBLIC_HOST=w4ai.o2o.kr
|
||||
# 로컬은 비워 둔다(기본값 localhost). 서버에 올릴 때만 실제 도메인을 적는다.
|
||||
# SITE_PUBLIC_HOST=web4ai.o2osolution.ai
|
||||
INDEXNOW_KEY= # 비우면 색인 통보를 건너뛴다(발행은 정상)
|
||||
AZURE_STORAGE_CONNECTION_STRING= # 비우면 로컬 발행만 한다
|
||||
AZURE_STORAGE_CONTAINER=
|
||||
|
||||
@ -35,7 +35,7 @@
|
||||
`azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 —
|
||||
**렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.**
|
||||
→ `docker compose restart solution-frontend` 후 `python scripts/republish_all.py`
|
||||
- **발행 호스트는 두 곳에 있고 같아야 한다.** 백엔드 `SITE_PUBLIC_HOST`(기본 `w4ai.o2o.kr`,
|
||||
- **발행 호스트는 두 곳에 있고 같아야 한다.** 백엔드 `SITE_PUBLIC_HOST`(기본 `web4ai.o2osolution.ai`,
|
||||
`site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부
|
||||
이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더
|
||||
재실행만으로는 안 되고 **백엔드에서 재발행**해 payload 를 다시 만들어야 한다.
|
||||
|
||||
@ -25,7 +25,9 @@ x-common-env: &common-env
|
||||
# 수신자(aud)를 대조한다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다.
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||
# ★ 프론트(VITE_PUBLISH_HOST)와 같은 값이어야 한다. canonical·og:url·sitemap 이 전부 이걸 쓴다.
|
||||
SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr}
|
||||
# ★ 기본값은 localhost 다. 운영 도메인을 기본으로 두면 .env 를 안 채운 로컬 빌드가
|
||||
# 조용히 운영 주소를 번들에 굽는다(실측 2026-09-03: 로컬 링크가 킹서버로 갔다).
|
||||
SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
||||
SITE_PAYLOAD_DIR: /app/out/payloads
|
||||
SITE_OUTPUT_DIR: /app/out/sites
|
||||
# ★ 프리렌더와 같은 값이어야 한다. 어긋나면 색인 통보가 403 이다.
|
||||
@ -148,7 +150,7 @@ services:
|
||||
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
|
||||
# ★ 발행 호스트를 프론트 .env 에 따로 적지 않는다 — 루트 .env 의 SITE_PUBLIC_HOST 를
|
||||
# 그대로 흘려보낸다. 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
|
||||
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr}
|
||||
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
||||
volumes:
|
||||
- ./package.json:/app/package.json
|
||||
- ./package-lock.json:/app/package-lock.json
|
||||
@ -186,11 +188,11 @@ services:
|
||||
environment:
|
||||
PORT: 3001
|
||||
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
|
||||
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr}
|
||||
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
||||
# ★ 이 둘은 **브라우저가** 부르는 주소다. 컨테이너 안에서 보는 주소가 아니라
|
||||
# 화면을 연 사람이 닿을 수 있는 주소여야 한다 — localhost 는 서버에 올리는 순간 틀린다.
|
||||
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800}
|
||||
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000}
|
||||
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
|
||||
VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-}
|
||||
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
|
||||
# 백엔드와 같은 값을 흘려보낸다(루트 .env 가 단일 출처).
|
||||
@ -231,7 +233,7 @@ services:
|
||||
VITE_API_BASE_URL: ${ADMIN_API_BASE_URL:-http://localhost:9801}
|
||||
# 사장님 앱은 다른 오리진이라 절대 URL 로 연다(admin/frontend/src/lib/solutionUrl.ts).
|
||||
# 이걸 안 넘기면 "빌더 열기" 가 서버에서 localhost:3000 을 가리켜 죽은 링크가 된다.
|
||||
VITE_SOLUTION_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000}
|
||||
VITE_SOLUTION_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
|
||||
volumes:
|
||||
- ./package.json:/app/package.json
|
||||
- ./package-lock.json:/app/package-lock.json
|
||||
@ -259,8 +261,8 @@ services:
|
||||
# ★ VITE_* 는 **번들에 구워진다.** .env 를 고쳐도 재빌드 전엔 안 바뀐다
|
||||
# → 주소를 바꿨으면 `./deploy.sh solution-site`.
|
||||
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost:9800}
|
||||
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr}
|
||||
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost:3000}
|
||||
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
||||
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
|
||||
# ⚠️ 비어 있으면 자동 로그인은 아예 꺼진다(기본값 없음). 채우면 번들에 구워진다.
|
||||
VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-}
|
||||
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
|
||||
|
||||
@ -91,7 +91,7 @@ docker compose exec solution-worker python scripts/republish_all.py
|
||||
|
||||
| 어디 | 무엇 | 기본값 |
|
||||
|---|---|---|
|
||||
| 백엔드 | `SITE_PUBLIC_HOST` (`site_payload.py` 의 `DEFAULT_HOST`) | `w4ai.o2o.kr` |
|
||||
| 백엔드 | `SITE_PUBLIC_HOST` (`site_payload.py` 의 `DEFAULT_HOST`) | `web4ai.o2osolution.ai` |
|
||||
| 프론트(빌더·`web` 컨테이너) | `VITE_PUBLISH_HOST` | compose 가 루트의 `SITE_PUBLIC_HOST` 를 흘려보낸다 |
|
||||
|
||||
★ 프론트 `.env` 에 따로 적지 않는다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
|
||||
|
||||
@ -40,7 +40,7 @@
|
||||
| 1차 업종 | 숙박, 법무법인, 성형외과 | 숙박, 카페, 음식점, 관광체험 | **반드시 사업 결정 필요.** 법률·의료를 넣으면 데이터 스키마뿐 아니라 규제·승인·감사 체계가 선행되어야 함 |
|
||||
| 입력 방식 | 고객 원본 사이트를 depth 3·최대 200페이지 크롤 | 공식 API, 사용자 확정 URL, 정적 HTML; 플랫폼 우회 수집 금지 | 현재의 출처·동의·robots 원칙 유지. 대규모 원본 사이트 수집은 별도 제품 모드로 분리 |
|
||||
| 동적 크롤링 | 정적 우선, Playwright 폴백 | 봇 탐지 우회로 변질될 수 있어 HeadlessAdapter 미등록·금지 | 포괄적인 Playwright 폴백은 채택하지 않음. 소유권이 검증된 고객 도메인에만 허용할지 법무·보안 결정 후 제한적으로 검토 |
|
||||
| 발행 도메인 | 고객 도메인 서브패스 권장, 서브도메인 차선 | 기본 `w4ai.o2o.kr/s/<slug>`, custom domain 경로 미완성 | 설계서 방향이 검색 권위 측면에서 더 적합. 고객 도메인 연결·소유권 검증을 우선 과제로 추가 |
|
||||
| 발행 도메인 | 고객 도메인 서브패스 권장, 서브도메인 차선 | 기본 `web4ai.o2osolution.ai/s/<slug>`, custom domain 경로 미완성 | 설계서 방향이 검색 권위 측면에서 더 적합. 고객 도메인 연결·소유권 검증을 우선 과제로 추가 |
|
||||
| 백엔드 구조 | Site/Brand 엔진, BFF, 엔진별 DB 분리 | 코드 한 벌 + 진입점 둘(:9800 사장님 / :9801 내부), 단일 PostgreSQL | 청중별 분리는 포트로 끝냈다. 엔진별 물리 분리는 트래픽·팀 소유권 근거가 생긴 뒤 수행 |
|
||||
| 작업 인프라 | Temporal, Redis, Celery 등 공통 인프라 | PostgreSQL 잡 큐 + lease + dead-letter | 현재 DB 큐 유지. 동일 책임의 인프라를 중복 도입하지 않음. 장기 워크플로 보상·분산 추적 요구가 확인될 때 Temporal 재평가 |
|
||||
| Fact Graph | 엔티티·predicate·snapshot, append-only | 업종 스키마 기반 key-value fact, 후보/노출/이력 상태 | 현재 모델은 발행 안전성에 적합. Brand 측정 재현성에 필요한 snapshot과 entity 관계만 점진적으로 확장 |
|
||||
|
||||
@ -5,6 +5,33 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-03 — 레포·발행 호스트 교체 — `o2o-site-AEO` / `web4ai.o2osolution.ai`
|
||||
|
||||
**왜**
|
||||
레포를 `castad/o2o-web4ai` → `Web4ai/o2o-site-AEO` 로, 공개 주소를 `w4ai.o2o.kr` →
|
||||
`web4ai.o2osolution.ai` 로 옮겼다. 옛 주소는 앞단에 vhost 가 없어 전 경로가 Apache 404 였다 —
|
||||
그런데 canonical·og:url·sitemap 이 전부 그 주소를 가리키고 있었다. **화면은 멀쩡하고 기계가
|
||||
읽는 값만 틀린** 상태라, 검색엔진 등록을 아무리 해도 색인이 안 되는 종류다.
|
||||
|
||||
**바꾼 것**
|
||||
- 기본 호스트를 쓰는 자리 전부(`site_payload.DEFAULT_HOST` · compose 의 `:-` 기본값 4곳 ·
|
||||
`vite.config.ts` allowedHosts · `.env.example` 둘 · `check_search_ready.py` · 데모 픽스처)
|
||||
- `docs/SERVERS.md`: 배포 경로 `~/data2/o2o-site-AEO` · 새 remote · 공개 주소 절
|
||||
- `init.sql`: `site.sites.thumbnail_url` 을 ALTER 절에 추가 — 아래 참조
|
||||
- `solution/frontend/public/google60b514c02fd6af4e.html`: 새 호스트로 다시 받은 구글 소유확인
|
||||
|
||||
**밟은 함정 둘**
|
||||
1. **`origin` 은 payload JSON 에 구워진다.** `.env` 만 고치고 프리렌더를 돌리면 안 바뀐다 —
|
||||
백엔드에서 재발행하거나 payload 의 `origin` 을 직접 고쳐야 한다.
|
||||
2. **`init.sql` 은 DB 최초 생성 때만 돈다.** 41커밋을 건너뛰며 배포했더니 `users.provider` 와
|
||||
`sites.thumbnail_url` 이 없어 로그인·쇼케이스가 통째로 죽었는데 **HTTP 는 200 이었다.**
|
||||
`thumbnail_url` 은 `CREATE TABLE` 에만 추가돼 있어서 **새 DB 는 되고 기존 DB 만** 깨졌다.
|
||||
|
||||
**검증** — 새 호스트로 canonical·og:url·robots.txt·sitemap 3건 전부 확인, 로그인·쇼케이스·
|
||||
장소검색 정상, 스키마 드리프트 0.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-03 — 랜딩 · 요금 · 쇼케이스 — 로그인 전 화면이 생겼다
|
||||
|
||||
**왜**
|
||||
|
||||
@ -35,7 +35,7 @@ Ubuntu 18.04.6 LTS · 24 core · RAM 125G · Docker 24.0.2 · Docker Compose v2.
|
||||
컨테이너 34개가 이미 돈다 (negosium · iquote · triple-pick · infinith · gitea · persona 등).
|
||||
**우리만 쓰는 서버가 아니다** — 포트와 디스크를 남의 것과 나눠 쓴다.
|
||||
|
||||
## 어디에 두나 — `/home/o2oadmin/data2/o2o-web4ai`
|
||||
## 어디에 두나 — `/home/o2oadmin/data2/o2o-site-AEO`
|
||||
|
||||
레포는 홈이 아니라 **`data2` 밑**에 둔다. 홈이 있는 루트 디스크와 다른 물리 디스크다.
|
||||
|
||||
@ -45,9 +45,14 @@ Ubuntu 18.04.6 LTS · 24 core · RAM 125G · Docker 24.0.2 · Docker Compose v2.
|
||||
```
|
||||
|
||||
`o2o-negosium` · `o2o-iquote` · `o2o-triple-pick` 이 모두 `/home/o2oadmin/data2/<레포명>` 이다.
|
||||
같은 규약을 따른다. (`git remote` 는 `https://gitea.o2o.kr/castad/o2o-web4ai.git` — gitea 도
|
||||
같은 규약을 따른다. (`git remote` 는 `https://gitea.o2o.kr/Web4ai/o2o-site-AEO.git` — gitea 도
|
||||
이 서버의 컨테이너다.)
|
||||
|
||||
★ 2026-09-03 에 레포를 `castad/o2o-web4ai` 에서 옮겼다. **compose 프로젝트명이
|
||||
`name: o2o-web4ai` 로 박혀 있어**(docker-compose.yml:3) 디렉토리를 옮겨도 컨테이너·네트워크·
|
||||
`site-out` 볼륨 이름이 그대로다 — 그래서 발행 산출물이 살아남는다. 반대로 **두 디렉토리에서
|
||||
동시에 `up` 하면 서로 잡아먹는다.** 옛 `~/data2/o2o-web4ai` 는 롤백용으로 남겨 뒀다.
|
||||
|
||||
## 포트 — 이 서버에서 우리가 잡은 자리
|
||||
|
||||
★ **`:80` 은 호스트 nginx 가 이미 물고 있다**(bible-chatbot·o2sound-voucher 를 라우팅 중).
|
||||
@ -97,7 +102,7 @@ ssh -N -L 3002:127.0.0.1:3002 -L 9801:127.0.0.1:9801 King_admin
|
||||
레포 루트에 스크립트 두 개가 있다. 서버에서 실행한다.
|
||||
|
||||
```bash
|
||||
cd ~/data2/o2o-web4ai
|
||||
cd ~/data2/o2o-site-AEO
|
||||
./deploy.sh # 전체 (git pull → build → up -d)
|
||||
./deploy.sh api # 그 서비스만
|
||||
./log.sh # 1=전체, 2번부터 개별 컨테이너
|
||||
@ -107,8 +112,8 @@ cd ~/data2/o2o-web4ai
|
||||
★ `deploy.sh api` 는 worker·api-admin 도 함께 갈아끼운다. 셋이 이미지 한 벌을 나눠 쓰기 때문이다 —
|
||||
안 그러면 옛 코드로 도는 컨테이너가 남는데 셋 다 "살아 있음" 이라 눈으로는 구분이 안 된다.
|
||||
|
||||
★ 서버에 gitea 자격증명이 없어 `git pull` 이 실패한다. 밖에서 밀어넣고 `./deploy.sh --no-pull`
|
||||
로 돌리거나, gitea 에 배포키를 등록해 원격을 SSH 로 바꾼다.
|
||||
★ 새 클론(`o2o-site-AEO`)은 gitea 자격증명이 통해서 `./deploy.sh` 를 서버에서 그대로 쓴다.
|
||||
옛 `o2o-web4ai` 는 안 됐다 — fetch 가 죽으면 deploy.sh 가 리셋을 건너뛰고 디스크 코드로 간다.
|
||||
|
||||
## DB
|
||||
|
||||
@ -117,13 +122,22 @@ cd ~/data2/o2o-web4ai
|
||||
붙여 두었으므로 **컴포즈를 고치지 않고 그대로 닿는다.** DB 는 만들어야 한다 —
|
||||
`postgres-init/init-data/init.sql` 한 벌이 스키마 전부다.
|
||||
|
||||
## 공개 주소 — `https://w4ai.o2o.kr` (2026-09-01 연결됨)
|
||||
★ **`init.sql` 은 DB 를 처음 만들 때만 돈다.** 이미 있는 DB 에는 파일 하단의 "기존 DB 보정(ALTER)"
|
||||
절만 손으로 돌려야 새 컬럼이 들어간다. 빠뜨리면 **HTTP 는 200 인데 기능만 죽는다** —
|
||||
실측(2026-09-03): `users.provider` 없음 → 로그인 전부 실패, `sites.thumbnail_url` 없음 →
|
||||
쇼케이스 전부 실패. 로그를 봐야 보인다. 컬럼을 `CREATE TABLE` 에만 추가하고 ALTER 절에
|
||||
안 적으면 **새 DB 는 되고 기존 DB 만 조용히 깨진다.**
|
||||
|
||||
## 공개 주소 — `https://web4ai.o2osolution.ai` (2026-09-03 기준)
|
||||
|
||||
```
|
||||
DNS A w4ai.o2o.kr → 59.14.81.3
|
||||
DNS A web4ai.o2osolution.ai → 59.14.81.3
|
||||
└ 앞단 Apache(2.4.18) → http://172.30.1.36:30030 → solution-site
|
||||
```
|
||||
|
||||
★ 옛 `w4ai.o2o.kr` 은 **쓰지 않는다.** DNS 는 아직 살아 있지만 앞단에 vhost 가 없어
|
||||
전 경로가 Apache 자체 404 다(인증서도 `CN=actions.o2o.kr`, 2024 만료).
|
||||
|
||||
`59.14.81.3` 은 사내망 입구다(`gitea.o2o.kr` 과 같은 IP). 킹서버의 `172.30.1.36` 은 **사설
|
||||
IP라 DNS 에 못 적는다** — 앞단 vhost 와 인증서는 이 레포 밖이고 인프라 담당이 잡는다.
|
||||
|
||||
|
||||
@ -444,3 +444,7 @@ ALTER TABLE company.users ALTER COLUMN password DROP NOT NULL;
|
||||
-- 생기므로, 인덱스를 먼저 만들면 "column provider_uid does not exist" 로 스크립트가 통째로 멈춘다.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON company.users (provider, provider_uid)
|
||||
WHERE deleted = FALSE AND provider_uid IS NOT NULL;
|
||||
|
||||
-- 2026-09-03 쇼케이스 카드 썸네일. CREATE TABLE 에만 있어서 기존 DB 가 조용히 깨졌다
|
||||
-- (실측: 킹서버에서 GET /v1/showcase 가 200 인데 내용은 비었다).
|
||||
ALTER TABLE site.sites ADD COLUMN IF NOT EXISTS thumbnail_url VARCHAR(500) NULL;
|
||||
|
||||
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):
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""발행본이 **인터넷에서** 검색엔진에 읽힐 준비가 됐는지 확인한다.
|
||||
|
||||
python scripts/check_search_ready.py (기본 https://w4ai.o2o.kr)
|
||||
python scripts/check_search_ready.py https://w4ai.o2o.kr
|
||||
python scripts/check_search_ready.py (기본 https://web4ai.o2osolution.ai)
|
||||
python scripts/check_search_ready.py https://web4ai.o2osolution.ai
|
||||
python scripts/check_search_ready.py --slug butter (특정 사이트만)
|
||||
|
||||
★ 왜 필요한가 — 색인 여부는 며칠~몇 주 뒤에나 알 수 있다. 그때까지 기다렸다가
|
||||
@ -171,7 +171,7 @@ def check_crawler_access(client: httpx.Client, origin: str, slug: str) -> None:
|
||||
# ── 실행 ─────────────────────────────────────────────────────────────────
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("origin", nargs="?", default="https://w4ai.o2o.kr", help="확인할 오리진")
|
||||
parser.add_argument("origin", nargs="?", default="https://web4ai.o2osolution.ai", help="확인할 오리진")
|
||||
parser.add_argument("--slug", help="이 사이트만 확인(기본: 사이트맵의 첫 사이트)")
|
||||
args = parser.parse_args()
|
||||
origin = args.origin.rstrip("/")
|
||||
|
||||
@ -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 "w4ai.o2o.kr"
|
||||
# 기본값은 localhost. 운영 도메인을 기본으로 두면 설정을 빠뜨린 환경이 조용히 운영 주소로
|
||||
# canonical·sitemap 을 굽는다 — 틀렸다는 걸 아무도 모른다.
|
||||
DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "localhost"
|
||||
|
||||
def _scheme(host: str) -> str:
|
||||
"""localhost 는 http 다. shared/lib/slug.ts publishUrl 과 같은 규칙."""
|
||||
return "http" if re.match(r"^(localhost|127\.0\.0\.1)(:\d+)?$", host) else "https"
|
||||
|
||||
# 링크 제목이 비었을 때 채우는 채널 이름. 없는 채널명을 지어내지 않기 위한 고정 표다.
|
||||
_CHANNEL_TITLE = {
|
||||
@ -550,7 +557,7 @@ def _publish_target(site, place_id: str, name: str) -> dict:
|
||||
name_slug = slugify(name)
|
||||
slug = f"{name_slug}-{place_id[:8]}" if name_slug else f"place-{place_id[:8]}"
|
||||
|
||||
return {"origin": f"https://{DEFAULT_HOST}", "basePath": f"/s/{slug}", "slug": slug}
|
||||
return {"origin": f"{_scheme(DEFAULT_HOST)}://{DEFAULT_HOST}", "basePath": f"/s/{slug}", "slug": slug}
|
||||
|
||||
|
||||
def publish_slug(place, site) -> str:
|
||||
@ -565,7 +572,7 @@ def publish_slug(place, site) -> str:
|
||||
def publish_origin() -> str:
|
||||
"""발행본이 사는 오리진. 썸네일 URL 도 여기서 나온다 —
|
||||
호스트를 새 env 로 또 두면 canonical 과 갈릴 수 있다(CLAUDE.md '발행 호스트는 두 곳')."""
|
||||
return f"https://{DEFAULT_HOST}"
|
||||
return f"{_scheme(DEFAULT_HOST)}://{DEFAULT_HOST}"
|
||||
|
||||
|
||||
def primary_media(snapshot: dict) -> dict | None:
|
||||
|
||||
@ -5,7 +5,7 @@ VITE_API_BASE_URL=http://localhost:9800
|
||||
# 커스텀 도메인이 붙기 전까지 <slug>.<이 값> 형태로 나간다.
|
||||
# ★ 로컬에서 compose 없이 띄울 때만 쓴다. compose 로 띄우면 루트 .env 의 SITE_PUBLIC_HOST 가
|
||||
# 주입돼 이 값을 덮는다 — 발행 호스트의 단일 출처는 루트 .env 다.
|
||||
VITE_PUBLISH_HOST=w4ai.o2o.kr
|
||||
VITE_PUBLISH_HOST=localhost
|
||||
|
||||
# 발행 사이트 렌더러 개발 서버. 에디터 상단 "발행본 사이트 열기" 가 이 주소를 연다.
|
||||
VITE_SITE_PREVIEW_URL=http://localhost:3000
|
||||
|
||||
1
solution/frontend/public/google60b514c02fd6af4e.html
Normal file
1
solution/frontend/public/google60b514c02fd6af4e.html
Normal file
@ -0,0 +1 @@
|
||||
google-site-verification: google60b514c02fd6af4e.html
|
||||
@ -0,0 +1 @@
|
||||
naver-site-verification: naver71762ee96e2e126623dc1da07ecae493.html
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -65,6 +65,15 @@ export function Step2PlaceSearch() {
|
||||
const search = usePlaceSearch(confirmedIdentity?.placeId ?? null);
|
||||
const [step, goToStep] = useWizardStep();
|
||||
const [placeUrl, setPlaceUrl] = useState('');
|
||||
/** ★ 후보가 있으면 붙여넣기 칸을 접는다. 펼쳐 두면 "자동으로 못 찾았다"는 신호로 읽힌다 —
|
||||
* 이 시점에는 자동 발견을 시도조차 안 했다(수집 직전에 서버가 한다). 목록에 내 가게가
|
||||
* 없을 때만 여는 탈출구다. */
|
||||
const [urlBoxOpen, setUrlBoxOpen] = useState(false);
|
||||
/** 고른 후보가 들고 온 네이버 플레이스 주소(검색 단계에서 서버가 찾아 준 값). */
|
||||
const naverUrlRef = useRef<string | null>(null);
|
||||
/** 확정 직후 2단계로 넘어가는 한 번은 초기화를 건너뛴다. */
|
||||
const justConfirmed = useRef(false);
|
||||
const foundCount = search.items.filter((i) => i.naver_place_url).length;
|
||||
|
||||
// ★ usePlaceSearch.searchPublic 과 같은 조건이다 — 여기서 더 느슨하면 눌러도 검색이 안 나가고
|
||||
// 화면만 후보 단계로 넘어간다(빈 목록).
|
||||
@ -90,7 +99,10 @@ export function Step2PlaceSearch() {
|
||||
const identity = await search.confirmPick(pick, nextIndustry);
|
||||
setPendingPick(null);
|
||||
if (!identity) return; // 실패 사유는 search.pickError 가 화면에 남긴다
|
||||
confirmIdentity(identity);
|
||||
justConfirmed.current = true;
|
||||
confirmIdentity(
|
||||
naverUrlRef.current ? {...identity, naverPlaceUrl: naverUrlRef.current} : identity,
|
||||
);
|
||||
advance(identity.placeId);
|
||||
};
|
||||
|
||||
@ -147,6 +159,9 @@ export function Step2PlaceSearch() {
|
||||
name: item.name ?? '',
|
||||
address: item.road_address ?? '',
|
||||
};
|
||||
// ★ 검색 단계에서 서버가 이미 찾아 둔 플레이스 주소를 그대로 물고 간다.
|
||||
// 이게 있으면 수집 직전에 다시 찾을 필요가 없고, 사장님에게 지도 주소를 묻지도 않는다.
|
||||
naverUrlRef.current = item.naver_place_url ?? null;
|
||||
const guessed = item.category != null ? (CATEGORY_TO_INDUSTRY[item.category] ?? null) : null;
|
||||
if (!guessed) {
|
||||
setPendingPick(pick);
|
||||
@ -180,8 +195,9 @@ export function Step2PlaceSearch() {
|
||||
const pickByUrl = async () => {
|
||||
const url = placeUrl.trim();
|
||||
if (!url) return;
|
||||
const identity = await search.confirmByUrl(url, industry);
|
||||
const identity = await search.confirmByUrl(url);
|
||||
if (!identity) return;
|
||||
justConfirmed.current = true;
|
||||
confirmIdentity(identity);
|
||||
advance(identity.placeId);
|
||||
};
|
||||
@ -220,7 +236,32 @@ export function Step2PlaceSearch() {
|
||||
? 'unavailable'
|
||||
: 'picking';
|
||||
|
||||
/**
|
||||
* 2단계에서 **뒤로 오면 처음(상호 입력)으로 되돌린다.**
|
||||
*
|
||||
* ★ 왜 확정 화면을 안 띄우나: 뒤로가기는 "다시 고르겠다" 는 뜻인데, 확정 화면만 뜨면
|
||||
* 다른 가게로 바꾸러 온 사장님이 목록을 못 찾는다. 후보 목록을 되살리는 방법도 있지만
|
||||
* 공개 검색이 유료 외부 API 라 뒤로 올 때마다 다시 부르게 된다.
|
||||
* ★ 이 화면을 **정상적으로 지나간** 경우(확정 직후 2단계로 넘어감)에는 돌지 않는다 —
|
||||
* `justConfirmed` 가 그 한 번을 막는다. 아니면 확정하자마자 스스로 지워 버린다.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!confirmedIdentity) return;
|
||||
if (justConfirmed.current) {
|
||||
justConfirmed.current = false;
|
||||
return;
|
||||
}
|
||||
clearIdentity();
|
||||
search.reset();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 들어올 때 한 번만 판단한다
|
||||
}, []);
|
||||
|
||||
const searchQuery = [storeName, location].filter(Boolean).join(' ');
|
||||
/** 뒤로 와서 목록을 다시 볼 때, 이미 고른 후보를 알아보게 한다. */
|
||||
const isPicked = (item: PlaceSearchItem) =>
|
||||
Boolean(confirmedIdentity) &&
|
||||
item.name === confirmedIdentity?.name &&
|
||||
(item.road_address ?? '') === (confirmedIdentity?.address ?? '');
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col justify-between bg-muted/30">
|
||||
@ -303,9 +344,11 @@ export function Step2PlaceSearch() {
|
||||
: 'border border-warning/30 bg-warning/10 text-warning',
|
||||
)}
|
||||
>
|
||||
{search.items.length > 0
|
||||
? '아래 목록에서 사장님 가게를 골라 주세요.'
|
||||
: '이 이름으로는 찾지 못했습니다. 아래에서 네이버 지도 주소로 찾아 주세요.'}
|
||||
{search.items.length === 0
|
||||
? '이 이름으로는 찾지 못했습니다. 아래에서 네이버 지도 주소로 찾아 주세요.'
|
||||
: foundCount > 0
|
||||
? `${foundCount}곳은 네이버 플레이스까지 찾았습니다 — 고르시면 영업시간·전화·사진을 그대로 가져옵니다.`
|
||||
: '아래 목록에서 사장님 가게를 골라 주세요.'}
|
||||
</p>
|
||||
|
||||
{search.pickError && (
|
||||
@ -335,14 +378,33 @@ export function Step2PlaceSearch() {
|
||||
)}
|
||||
{/* 고르기 전에 어떤 업종으로 시작하는지 먼저 보여준다 — 고른 뒤에 알면 늦다. */}
|
||||
<IndustryBadge category={item.category} />
|
||||
{/* ★ 플레이스를 찾았는지 **여기서** 보여준다. 데이터만 싣고 표시를 안 하면
|
||||
사장님은 자동으로 찾았는지 알 길이 없어 지도 주소를 또 찾으러 간다. */}
|
||||
{item.naver_place_url && (
|
||||
<Badge variant="success" className="gap-1 text-[10px]">
|
||||
<Check className="size-3" />
|
||||
네이버 플레이스 찾음
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 flex items-start gap-1 text-xs text-muted-foreground">
|
||||
<MapPin className="mt-px size-3 shrink-0" />
|
||||
<span>{item.road_address ?? '주소 없음'}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-lg bg-primary px-3 py-1.5 text-[11px] font-bold text-primary-foreground">
|
||||
{search.isConfirming ? '확정 중...' : '이 가게예요'}
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-lg px-3 py-1.5 text-[11px] font-bold',
|
||||
isPicked(item)
|
||||
? 'bg-success text-success-foreground'
|
||||
: 'bg-primary text-primary-foreground',
|
||||
)}
|
||||
>
|
||||
{search.isConfirming
|
||||
? '확정 중...'
|
||||
: isPicked(item)
|
||||
? '고른 가게'
|
||||
: '이 가게예요'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
@ -357,6 +419,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}
|
||||
@ -365,6 +436,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 {STEP_NUMBER, useWizardStep} from './wizardUrl';
|
||||
@ -40,9 +45,67 @@ export function Step3DataReview() {
|
||||
const photos = useBuilderStore((s) => s.photos);
|
||||
const [, goToStep] = useWizardStep();
|
||||
|
||||
const confirmIdentity = useBuilderStore((s) => s.confirmIdentity);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const unverified = useUnverifiedFields();
|
||||
const placeId = confirmedIdentity?.placeId ?? null;
|
||||
const collect = useCollectFlow(placeId);
|
||||
/** 로그인 관문을 이 화면에서 연다 — 위저드에서 서버를 처음 부르는 지점이 여기다. */
|
||||
const [signingIn, setSigningIn] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
/** 찾아 둔 주소가 있어도 사장님이 직접 넣겠다고 하면 펼친다. */
|
||||
const [urlBoxOpen, setUrlBoxOpen] = useState(false);
|
||||
|
||||
/**
|
||||
* 수집 시작 — **위저드에서 서버를 처음 부르는 지점.**
|
||||
*
|
||||
* ★ 1단계의 확정은 화면에만 남는다(usePlaceSearch). 그래서 여기서 세 가지를 순서대로 한다:
|
||||
* 로그인 → 사업장 생성·검증(ensureServerPlace) → 수집. 로그인을 앞 단계로 올리면
|
||||
* 아무것도 못 본 사장님에게 가입을 요구하게 된다(docs/PRODUCT.md "로그인 최소").
|
||||
*/
|
||||
/** 로그인 모달을 띄우기 직전의 신원. 로그인이 끝나면 그대로 이어 돌린다. */
|
||||
const pendingAfterSignIn = useRef<typeof confirmedIdentity>(null);
|
||||
|
||||
const beginCollect = async (identity = confirmedIdentity) => {
|
||||
if (!identity) return;
|
||||
if (!user && !getAccessToken()) {
|
||||
// ★ 로그인 뒤에 이어서 돌 값을 들고 있는다. 안 들고 있으면 로그인만 하고 제자리로
|
||||
// 돌아와, 사장님이 같은 버튼을 두 번 눌러야 한다(실측 2026-09-03).
|
||||
pendingAfterSignIn.current = identity;
|
||||
setSigningIn(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!identity.placeId) {
|
||||
setIsUploading(true);
|
||||
const uploaded = await ensureServerPlace(identity, industry);
|
||||
setIsUploading(false);
|
||||
if (!uploaded?.placeId) return;
|
||||
confirmIdentity(uploaded);
|
||||
return; // placeId 가 스토어에 박히면 아래 자동 수집 effect 가 이어받는다
|
||||
}
|
||||
void collect.crawl();
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 사장님이 붙여넣은 주소를 받는다.
|
||||
*
|
||||
* ★ 서버에 사업장이 없으면 `collect.addLink` 는 **조용히 아무것도 안 한다**(placeId 가
|
||||
* 없어서 첫 줄에서 return). 그러면 입력칸만 비워져 사장님은 실패로 읽는다(실측).
|
||||
* 그래서 그 경우엔 신원에 주소를 싣고 같은 흐름(로그인 → 생성 → 검증 → 수집)으로 보낸다.
|
||||
*/
|
||||
const addUrl = async (url: string, channel: LinkChannelCode, title: string) => {
|
||||
if (confirmedIdentity?.placeId) {
|
||||
await collect.addLink(url, channel, title);
|
||||
return;
|
||||
}
|
||||
if (!confirmedIdentity) return;
|
||||
const next = {...confirmedIdentity, naverPlaceUrl: url};
|
||||
confirmIdentity(next);
|
||||
await beginCollect(next);
|
||||
};
|
||||
const collectPhase = collect.phase;
|
||||
const startAutomaticCollect = collect.crawl;
|
||||
const autoCollectStarted = useRef(false);
|
||||
@ -75,6 +138,15 @@ export function Step3DataReview() {
|
||||
|
||||
const config = INDUSTRY_CONFIGS[industry];
|
||||
const canCollect = confirmedIdentity?.origin === 'external';
|
||||
/** ★ 버튼을 언제 열어 두나.
|
||||
* `confirmedCount` 는 **서버에 사업장이 있어야** 나오는 값이다. 그것만 보면 로그인 전에는
|
||||
* 버튼이 영원히 잠겨, 자동 발견이 돌 기회조차 없다(실측 2026-09-03: 후보를 골라도
|
||||
* "주소를 먼저 붙여넣어 주세요" 로 잠긴 채 멈춘다).
|
||||
* 아직 서버에 안 올렸으면(=placeId 없음) 자동 발견을 시도해 볼 여지가 있으므로 열어 둔다.
|
||||
* 올렸는데도 채널이 0이면 그때는 자동 발견이 실패한 것이라 붙여넣기를 요구한다. */
|
||||
const pendingUrl = confirmedIdentity?.naverPlaceUrl;
|
||||
const autoLookupPending = canCollect && !placeId;
|
||||
const hasSource = collect.confirmedCount > 0 || Boolean(pendingUrl) || autoLookupPending;
|
||||
/** 아직 실제 수집이 붙지 않은 줄들 — 업종 예시다. 그렇게 부른다. */
|
||||
const isSample = !confirmedIdentity || confirmedIdentity.origin === 'owner';
|
||||
|
||||
@ -89,13 +161,52 @@ export function Step3DataReview() {
|
||||
? 'review'
|
||||
: 'ready';
|
||||
|
||||
// ★ /login 으로 튕기지 않는다. 여기까지 쌓은 위저드 상태는 주소창에 없어서 돌아올 길이 없다.
|
||||
if (signingIn) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center bg-muted/30 px-4 py-10">
|
||||
<SignInForm
|
||||
submitLabel="로그인하고 정보 가져오기"
|
||||
onSignedIn={() => {
|
||||
setSigningIn(false);
|
||||
const resume = pendingAfterSignIn.current;
|
||||
pendingAfterSignIn.current = null;
|
||||
if (resume) void beginCollect(resume);
|
||||
}}
|
||||
header={
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="text-sm font-bold">정보를 가져오려면 로그인해 주세요</h1>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
고르신 가게는 그대로 있습니다. 수집한 정보를 사장님 계정에 담아 두기 위해
|
||||
여기서 한 번만 받습니다.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSigningIn(false)}
|
||||
className="w-full text-center text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
나중에 하기
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col justify-between bg-muted/30">
|
||||
<WizardStage
|
||||
current={STEP_NUMBER.collect}
|
||||
wide={stage === 'review' || stage === 'channels'}
|
||||
title={STAGE_TITLE[stage]}
|
||||
description={STAGE_DESCRIPTION[stage]}
|
||||
title={stage === 'ready' && pendingUrl ? '네이버 플레이스를 찾았습니다' : STAGE_TITLE[stage]}
|
||||
description={
|
||||
stage === 'ready' && pendingUrl
|
||||
? '이 가게에서 영업시간·전화·편의시설과 사진을 그대로 가져옵니다.'
|
||||
: STAGE_DESCRIPTION[stage]
|
||||
}
|
||||
>
|
||||
{stage === 'ready' && (
|
||||
<div className="space-y-3">
|
||||
@ -113,22 +224,52 @@ export function Step3DataReview() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ★ 1순위 경로. URL 만 있으면 100% 되므로 화면에서도 맨 위에 둔다. */}
|
||||
{canCollect && <ChannelUrlInput onAdd={collect.addLink} isBusy={collect.isAdding} />}
|
||||
{/* ★ 이미 찾아 둔 주소가 있으면 붙여넣기 안내를 접는다. 펼쳐 두면 사장님은
|
||||
"또 못 찾았구나" 로 읽고 지도를 열러 간다 — 찾아 놓고 그러는 건 최악이다. */}
|
||||
{canCollect && pendingUrl && !urlBoxOpen ? (
|
||||
<div className="rounded-2xl border border-success/40 bg-success/5 p-4">
|
||||
<p className="flex items-center gap-1.5 text-xs font-bold">
|
||||
<Check className="size-3.5 text-success" />
|
||||
네이버 플레이스를 찾았습니다
|
||||
</p>
|
||||
<p className="mt-1 truncate text-[11px] text-muted-foreground">{pendingUrl}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUrlBoxOpen(true)}
|
||||
className="mt-2 cursor-pointer text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
이 가게가 아닌가요? — 주소 직접 넣기
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
canCollect && (
|
||||
<ChannelUrlInput
|
||||
onAdd={addUrl}
|
||||
isBusy={collect.isAdding || isUploading}
|
||||
searchQuery={[confirmedIdentity?.name, confirmedIdentity?.address]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{canCollect && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={collect.crawl}
|
||||
disabled={collect.confirmedCount === 0}
|
||||
onClick={() => void beginCollect()}
|
||||
disabled={isUploading || !hasSource}
|
||||
>
|
||||
<Search />
|
||||
<span>
|
||||
{collect.confirmedCount === 0
|
||||
{!hasSource
|
||||
? '주소를 먼저 붙여넣어 주세요'
|
||||
: `등록한 ${collect.confirmedCount}개 주소에서 정보 가져오기`}
|
||||
: collect.confirmedCount > 0
|
||||
? `등록한 ${collect.confirmedCount}개 주소에서 정보 가져오기`
|
||||
: isUploading
|
||||
? '내 가게를 찾는 중입니다...'
|
||||
: '내 가게 정보 자동으로 가져오기'}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
@ -217,20 +358,26 @@ export function Step3DataReview() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ChannelUrlInput onAdd={collect.addLink} isBusy={collect.isAdding} />
|
||||
<ChannelUrlInput
|
||||
onAdd={collect.addLink}
|
||||
isBusy={collect.isAdding}
|
||||
searchQuery={[confirmedIdentity?.name, confirmedIdentity?.address].filter(Boolean).join(' ')}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={collect.crawl}
|
||||
disabled={collect.confirmedCount === 0}
|
||||
onClick={() => void beginCollect()}
|
||||
disabled={isUploading || !hasSource}
|
||||
>
|
||||
<Search />
|
||||
<span>
|
||||
{collect.confirmedCount === 0
|
||||
{!hasSource
|
||||
? '확정한 채널이 없습니다'
|
||||
: `확정한 ${collect.confirmedCount}개 채널에서 수집하기`}
|
||||
: collect.confirmedCount > 0
|
||||
? `확정한 ${collect.confirmedCount}개 채널에서 수집하기`
|
||||
: '네이버 플레이스에서 정보 가져오기'}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -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);
|
||||
|
||||
// ★ 로그인을 마친 사람의 용건은 "내 것"이다 — 사장님 앱은 /sites 로 보낸다.
|
||||
|
||||
@ -79,6 +79,9 @@ export interface ConfirmedIdentity {
|
||||
externalPlaceId?: string;
|
||||
/** 후보가 들고 있던 공식 홈페이지. 확정 시 공식 채널로 등록된다. */
|
||||
placeUrl?: string;
|
||||
/** 사장님이 붙여넣은 네이버 플레이스 주소. **아직 서버에 안 보냈다** —
|
||||
* 수집 직전 로그인한 뒤에 이 값으로 사업장을 검증한다(usePlaceSearch.confirmByUrl). */
|
||||
naverPlaceUrl?: string;
|
||||
}
|
||||
|
||||
export interface ApplyPlaceOptions {
|
||||
|
||||
@ -28,7 +28,7 @@ export default defineConfig({
|
||||
// ★ Vite 6 는 모르는 Host 헤더를 403 "Blocked request" 로 막는다(DNS rebinding 방어).
|
||||
// 운영은 앞단 nginx 가 Host 를 그대로 넘기므로 발행 호스트를 여기 넣어야 화면이 뜬다.
|
||||
// 빠뜨리면 프록시는 정상인데 앱만 전부 403 이다.
|
||||
allowedHosts: [process.env.VITE_PUBLISH_HOST ?? 'w4ai.o2o.kr'],
|
||||
allowedHosts: [process.env.VITE_PUBLISH_HOST ?? 'web4ai.o2osolution.ai'],
|
||||
// 발행 사이트는 별도 정적 서버(:3001)가 만들고 서빙하지만, 사용자는 admin 과 같은
|
||||
// origin(:3000)의 `/s/<slug>` 로 접근한다. 프로세스 포트만 분리하고 공개 URL은 하나로 둔다.
|
||||
proxy: {
|
||||
|
||||
@ -52,7 +52,7 @@ export interface SiteMeta {
|
||||
siteId: string;
|
||||
placeId: string;
|
||||
status: SiteStatus;
|
||||
/** 발행 도메인. 커스텀 도메인이 없으면 `<slug>.w4ai.o2o.kr` 같은 기본 호스트. */
|
||||
/** 발행 도메인. 커스텀 도메인이 없으면 `<slug>.web4ai.o2osolution.ai` 같은 기본 호스트. */
|
||||
origin: string;
|
||||
/** URL 경로 프리픽스. 루트 발행이면 '' (빈 문자열). */
|
||||
basePath: string;
|
||||
|
||||
@ -93,7 +93,7 @@ export const MOONLIGHT_STAY_PAYLOAD: SitePayload = {
|
||||
siteId: 'demo-site-0001',
|
||||
placeId: 'demo-place-0001',
|
||||
status: SiteStatus.PUBLISHED,
|
||||
origin: 'https://moonlight-stay-jeju.w4ai.o2o.kr',
|
||||
origin: 'https://moonlight-stay-jeju.web4ai.o2osolution.ai',
|
||||
basePath: '',
|
||||
slug: 'moonlight-stay-jeju',
|
||||
publishedAt: '2026-08-20T10:00:00+09:00',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user