[feat] solution/backend: 상호명 공개 검색 + 업종 자동 판별 — 랜딩이 로그인 앞에서 부른다
랜딩 첫 화면이 상호명을 받으려면 검색이 로그인 앞에 있어야 하는데, 후보 조회는
place_id 와 토큰을 둘 다 요구했다. 로그인 관문을 에디터 진입 하나로 되돌려 놓고도
(b94daa9) API 는 그대로였다.
업종은 AI 를 한 번 더 부를 필요가 없다 — 카카오·네이버 검색 응답에 분류가 이미
들어 있고(category_group_code / category_name), 지금까지 받아 놓고 안 썼다.
- place.py: GET /v1/place/search 신설(인증 없음). ★ /{place_id} 앞에 둬야 한다 —
뒤에 두면 "search" 가 place_id 로 잡혀 422 다
- place_category: AD5·CE7·FD6 우선, 없으면 분류 문자열. 못 정하면 None —
억지로 고르면 틀린 스키마로 시작한다. HP8 은 피부과·성형외과일 때만
- kakao: KakaoPlace 에 category_group_code. 한글 분류는 바뀌어도 코드는 안 바뀐다
- rate_limit: 인증 없이 유료 API 를 부르는 경로라 IP 당 분당 20회(프로세스 메모리)
- 확정 경로(verify/candidates)는 인증 유지 — 남의 place_id 존재 여부를 열지 않는다
전체 562 passed
This commit is contained in:
parent
e8cda02a4b
commit
2026fde80f
@ -5,6 +5,28 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-09-03 — 상호명 검색을 로그인 앞으로 · 업종은 LLM 없이 정한다
|
||||||
|
|
||||||
|
**왜**
|
||||||
|
랜딩 첫 화면에서 상호명을 치게 하려면 검색이 로그인 앞에 있어야 하는데,
|
||||||
|
후보 조회는 `place_id` 와 토큰을 둘 다 요구했다(`place.py` 확정 경로). 로그인 관문을
|
||||||
|
에디터 진입 하나로 되돌려 놓고도 API 는 그대로였다.
|
||||||
|
그리고 업종은 사장님에게 고르게 하고 있었는데 — 경계(베이커리 카페, 브런치집)에서 멈춘다.
|
||||||
|
|
||||||
|
**한 일**
|
||||||
|
- `GET /v1/place/search` 신설(인증 없음). 사업장을 만들지도, 우리 DB 를 읽지도 않는다.
|
||||||
|
확정 경로(`/{place_id}/verify/candidates`)는 인증을 그대로 둔다 — 남의 place_id 존재
|
||||||
|
여부까지 열 이유가 없다
|
||||||
|
- `place_category.guess_category()`: 카카오 `category_group_code`(AD5·CE7·FD6) 우선,
|
||||||
|
없으면 분류 문자열. **LLM 호출 0건** — 상호명 검색 응답에 이미 들어 있던 값이다
|
||||||
|
- 못 정하면 `None`. 억지로 고르지 않는다 — 업종은 수집 스키마와 JSON-LD 타입을 통째로
|
||||||
|
정해서 틀리면 되돌리는 비용이 크다. HP8(병원)은 피부과·성형외과일 때만 받는다
|
||||||
|
- `rate_limit`: 인증 없이 유료 외부 API 를 부르는 경로라 IP 당 분당 20회.
|
||||||
|
프로세스 메모리라 완전하지 않다(앞단 nginx 가 제대로 된 자리)
|
||||||
|
|
||||||
|
**검증** — 전체 562 passed. 공개 응답에 place_id·전화·좌표가 안 나가는 것,
|
||||||
|
검색만으로 사업장이 생기지 않는 것을 테스트로 고정.
|
||||||
|
|
||||||
## 2026-09-03 — 발행하면 썸네일이 남는다 (랜딩 쇼케이스용)
|
## 2026-09-03 — 발행하면 썸네일이 남는다 (랜딩 쇼케이스용)
|
||||||
|
|
||||||
**왜**
|
**왜**
|
||||||
|
|||||||
34
solution/backend/common/utils/rate_limit.py
Normal file
34
solution/backend/common/utils/rate_limit.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
"""IP 단위 호출 제한 — 프로세스 메모리에만 있는 아주 단순한 것.
|
||||||
|
|
||||||
|
★ 한계를 먼저 적는다. 프로세스가 여럿이면 한도가 그 수만큼 곱해지고, 재시작하면 리셋된다.
|
||||||
|
제대로 하려면 앞단(nginx `limit_req`)이나 공유 저장소가 필요하다.
|
||||||
|
|
||||||
|
★ 그런데도 두는 이유: 이걸 쓰는 곳이 **인증 없이 유료 외부 API 를 부르는 경로**다.
|
||||||
|
방어가 0 이면 새로고침을 누르고 있는 것만으로 요금이 나간다
|
||||||
|
(카카오 키워드 검색은 무료 한도를 넘기면 건당 2원 — services/external/kakao.py 주석).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
|
||||||
|
_hits: dict[str, deque] = defaultdict(deque)
|
||||||
|
|
||||||
|
|
||||||
|
def allow(key: str, limit: int, window_sec: float) -> bool:
|
||||||
|
"""key 가 window_sec 안에서 limit 번을 넘지 않았으면 True(그리고 1회로 센다)."""
|
||||||
|
now = time.monotonic()
|
||||||
|
bucket = _hits[key]
|
||||||
|
while bucket and now - bucket[0] > window_sec:
|
||||||
|
bucket.popleft()
|
||||||
|
if len(bucket) >= limit:
|
||||||
|
return False
|
||||||
|
bucket.append(now)
|
||||||
|
# 안 쓰는 키가 쌓이는 걸 막는다. 호출이 뜸하면 자연히 비워진다.
|
||||||
|
if not bucket:
|
||||||
|
_hits.pop(key, None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def reset():
|
||||||
|
"""테스트 전용 — 프로세스 상태를 비운다."""
|
||||||
|
_hits.clear()
|
||||||
@ -1,7 +1,7 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
|
||||||
from common.enums import PlaceCategory, PlaceStatus
|
from common.enums import PlaceCategory, PlaceStatus
|
||||||
from common.models.gmodel import PageParams, UserInfo
|
from common.models.gmodel import PageParams, UserInfo
|
||||||
@ -22,6 +22,7 @@ from .protocol import (
|
|||||||
Res_LinkList,
|
Res_LinkList,
|
||||||
Res_Place,
|
Res_Place,
|
||||||
Res_PlaceList,
|
Res_PlaceList,
|
||||||
|
Res_PlaceSearch,
|
||||||
Res_StartCollect,
|
Res_StartCollect,
|
||||||
Res_StartCopy,
|
Res_StartCopy,
|
||||||
Res_StartVision,
|
Res_StartVision,
|
||||||
@ -46,6 +47,26 @@ async def list_places(
|
|||||||
return RemoveNoneResponse(await service.list_places(user_info, pg, search, category, status))
|
return RemoveNoneResponse(await service.list_places(user_info, pg, search, category, status))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
path="/search",
|
||||||
|
response_model=Res_PlaceSearch,
|
||||||
|
summary="상호명 공개 검색",
|
||||||
|
description="상호명으로 외부 장소 DB(카카오 → 없으면 네이버)를 찾아 후보를 그대로 돌려준다. "
|
||||||
|
"★ 인증이 없다 — 랜딩 첫 화면이 부른다. 사업장을 만들지도, 우리 DB 를 읽지도 않는다. "
|
||||||
|
"★ 응답의 category 는 외부 분류에서 **추정한 기본값**이다. None 이면 못 정한 것이고, "
|
||||||
|
"값이 있어도 확정이 아니다 — 화면은 언제나 바꿀 수 있게 둔다.",
|
||||||
|
)
|
||||||
|
async def search_places_public(
|
||||||
|
request: Request,
|
||||||
|
service: PlaceService = Depends(),
|
||||||
|
q: str = Query(..., min_length=2, max_length=100, description="상호명"),
|
||||||
|
):
|
||||||
|
# ★ 이 라우트는 반드시 `/{place_id}` **앞에** 있어야 한다. FastAPI 는 등록 순서로 매칭해서,
|
||||||
|
# 뒤에 두면 "search" 가 place_id 로 잡혀 422 가 난다 — 조용히 틀리는 종류다.
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
return RemoveNoneResponse(await service.search_places_public(q, client_ip))
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
path="",
|
path="",
|
||||||
response_model=Res_Place,
|
response_model=Res_Place,
|
||||||
|
|||||||
@ -208,6 +208,33 @@ class Res_VerifyCandidates(Res_WebPacketProtocol):
|
|||||||
candidates: list[PlaceCandidate] = []
|
candidates: list[PlaceCandidate] = []
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 공개 상호명 검색 (랜딩 첫 화면) ----------------------------------------
|
||||||
|
class PlaceSearchItem(WebPacketProtocol):
|
||||||
|
"""공개 검색 결과 1건.
|
||||||
|
|
||||||
|
★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·company_id·소유자)은
|
||||||
|
하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다.
|
||||||
|
★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고,
|
||||||
|
확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다."""
|
||||||
|
|
||||||
|
name: str = ""
|
||||||
|
road_address: Optional[str] = None
|
||||||
|
category_name: Optional[str] = None # 외부 DB 의 분류 문자열(예: "숙박>펜션")
|
||||||
|
# 추정 업종. ★ None 이면 못 정한 것이다 — 화면이 사장님에게 직접 고르게 한다.
|
||||||
|
# 값이 있어도 확정이 아니다. 화면은 언제나 바꿀 수 있게 둔다(경계 업종이 실제로 있다).
|
||||||
|
category: Optional[PlaceCategory] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Res_PlaceSearch(Res_WebPacketProtocol):
|
||||||
|
"""상호명 공개 검색 결과.
|
||||||
|
|
||||||
|
★ 인증이 없다. 랜딩 첫 화면에서 상호명을 치면 바로 부른다 —
|
||||||
|
만들어 보기도 전에 로그인을 요구하지 않기로 한 결정(로그인 관문은 에디터 진입 하나)의 연장이다."""
|
||||||
|
|
||||||
|
source: Optional[ExternalPlaceSource] = None
|
||||||
|
items: list[PlaceSearchItem] = []
|
||||||
|
|
||||||
|
|
||||||
class Req_StartCopy(PlaceProtocol):
|
class Req_StartCopy(PlaceProtocol):
|
||||||
"""소개문·FAQ 생성 시작.
|
"""소개문·FAQ 생성 시작.
|
||||||
|
|
||||||
|
|||||||
4
solution/backend/services/external/kakao.py
vendored
4
solution/backend/services/external/kakao.py
vendored
@ -97,6 +97,9 @@ class KakaoPlace:
|
|||||||
latitude: Optional[float] # y
|
latitude: Optional[float] # y
|
||||||
longitude: Optional[float] # x
|
longitude: Optional[float] # x
|
||||||
category_name: Optional[str]
|
category_name: Optional[str]
|
||||||
|
# ★ 업종 자동 판별의 입력. 한글 분류(category_name)는 카카오가 언제든 바꾸지만 이 코드는 안 바뀐다 —
|
||||||
|
# AD5 숙박 · CE7 카페 · FD6 음식점 · HP8 병원. 매핑은 services/place_category.py 가 소유한다.
|
||||||
|
category_group_code: Optional[str]
|
||||||
place_url: Optional[str]
|
place_url: Optional[str]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -110,6 +113,7 @@ class KakaoPlace:
|
|||||||
latitude=_to_float(doc.get("y")), # ★ y = 위도
|
latitude=_to_float(doc.get("y")), # ★ y = 위도
|
||||||
longitude=_to_float(doc.get("x")), # ★ x = 경도
|
longitude=_to_float(doc.get("x")), # ★ x = 경도
|
||||||
category_name=(doc.get("category_name") or None),
|
category_name=(doc.get("category_name") or None),
|
||||||
|
category_group_code=(doc.get("category_group_code") or None),
|
||||||
place_url=(doc.get("place_url") or None),
|
place_url=(doc.get("place_url") or None),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
70
solution/backend/services/place_category.py
Normal file
70
solution/backend/services/place_category.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"""외부 장소 DB 의 분류 → 우리 업종(PlaceCategory).
|
||||||
|
|
||||||
|
★ 업종 판별에 LLM 을 부르지 않는다. 상호명으로 카카오·네이버를 부르는 건 어차피 하는 일이고,
|
||||||
|
그 응답에 분류가 함께 온다(`category_name`, 카카오는 `category_group_code` 까지).
|
||||||
|
지금까지 받아 놓고 쓰지 않았을 뿐이다 — 여기서 그 값을 업종으로 옮긴다.
|
||||||
|
|
||||||
|
★ 못 정하면 None 이다. 억지로 하나를 고르지 않는다. 업종은 수집 스키마와 JSON-LD 타입을
|
||||||
|
통째로 정하는 값이라, 틀린 업종으로 시작하면 되돌리는 비용이 크다.
|
||||||
|
None 이면 화면이 사장님에게 직접 묻는다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from common.enums import PlaceCategory
|
||||||
|
|
||||||
|
# 카카오 category_group_code. 한글 분류 문자열은 카카오가 언제든 바꾸지만 이 코드는 안 바뀐다.
|
||||||
|
# ★ HP8(병원)은 여기 없다 — 병원 전체가 아니라 피부과·성형외과만 열려 있어서,
|
||||||
|
# 코드만으로는 우리 업종인지 알 수 없다. 아래 키워드로 한 번 더 좁힌다.
|
||||||
|
_BY_GROUP_CODE = {
|
||||||
|
"AD5": PlaceCategory.LODGING,
|
||||||
|
"CE7": PlaceCategory.CAFE,
|
||||||
|
"FD6": PlaceCategory.RESTAURANT,
|
||||||
|
}
|
||||||
|
_HOSPITAL_GROUP_CODE = "HP8"
|
||||||
|
|
||||||
|
# 분류 문자열 키워드. 네이버는 group_code 를 주지 않아 이 경로만 탄다.
|
||||||
|
# ★ 순서가 결과를 바꾼다. 카카오·네이버 모두 카페를 "음식점 > 카페" 아래 두기 때문에
|
||||||
|
# CAFE 를 RESTAURANT 보다 먼저 봐야 한다. 뒤집으면 카페가 전부 음식점이 된다.
|
||||||
|
_KEYWORDS: list[tuple[PlaceCategory, tuple[str, ...]]] = [
|
||||||
|
# ★ "피부"·"성형" 이 아니라 "피부과"·"성형외과" 다. 앞의 둘로 보면 피부관리실(에스테틱)이
|
||||||
|
# 병원으로 걸린다 — 의료 광고 규제가 걸리는 업종이라 잘못 붙이면 가장 비싸다.
|
||||||
|
(PlaceCategory.CLINIC, ("피부과", "성형외과")),
|
||||||
|
(PlaceCategory.LODGING, ("숙박", "펜션", "호텔", "모텔", "리조트", "게스트하우스", "민박")),
|
||||||
|
(PlaceCategory.CAFE, ("카페", "커피", "베이커리", "제과", "디저트")),
|
||||||
|
(PlaceCategory.RESTAURANT, ("음식점", "한식", "일식", "중식", "양식", "분식",
|
||||||
|
"주점", "술집", "치킨", "횟집", "뷔페", "패스트푸드")),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def guess_category(
|
||||||
|
category_name: Optional[str], group_code: Optional[str] = None
|
||||||
|
) -> Optional[PlaceCategory]:
|
||||||
|
"""분류 문자열(+ 카카오 그룹코드)로 업종을 추정한다. 모르면 None.
|
||||||
|
|
||||||
|
category_name 예 — 카카오 "가정,생활 > 숙박 > 펜션" · 네이버 "숙박>펜션"
|
||||||
|
"""
|
||||||
|
text = (category_name or "").replace(" ", "")
|
||||||
|
code = (group_code or "").strip().upper()
|
||||||
|
|
||||||
|
if code == _HOSPITAL_GROUP_CODE:
|
||||||
|
# 병원이라는 것까지만 안다. 진료과가 문자열에 없으면 우리 업종인지 알 수 없다.
|
||||||
|
return PlaceCategory.CLINIC if _match(text) is PlaceCategory.CLINIC else None
|
||||||
|
|
||||||
|
if code in _BY_GROUP_CODE:
|
||||||
|
by_code = _BY_GROUP_CODE[code]
|
||||||
|
# ★ 그룹코드를 문자열로 덮는 경우가 하나 있다: 카카오가 베이커리·브런치 가게를
|
||||||
|
# FD6(음식점)으로 주면서 분류 문자열엔 "카페"를 다는 일이 있다. 어느 쪽이 맞는지는
|
||||||
|
# 실측하지 않았다 — 사장님이 바꿀 수 있으니 이름이 더 구체적인 쪽을 기본값으로 둔다.
|
||||||
|
if by_code is PlaceCategory.RESTAURANT and _match(text) is PlaceCategory.CAFE:
|
||||||
|
return PlaceCategory.CAFE
|
||||||
|
return by_code
|
||||||
|
|
||||||
|
return _match(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _match(text: str) -> Optional[PlaceCategory]:
|
||||||
|
for category, words in _KEYWORDS:
|
||||||
|
if any(word in text for word in words):
|
||||||
|
return category
|
||||||
|
return None
|
||||||
@ -28,8 +28,10 @@ from router.v1.place.protocol import (
|
|||||||
Res_LinkList,
|
Res_LinkList,
|
||||||
Res_Place,
|
Res_Place,
|
||||||
Res_PlaceList,
|
Res_PlaceList,
|
||||||
|
Res_PlaceSearch,
|
||||||
Res_VerifyCandidates,
|
Res_VerifyCandidates,
|
||||||
PlaceCandidate,
|
PlaceCandidate,
|
||||||
|
PlaceSearchItem,
|
||||||
Res_StartCollect,
|
Res_StartCollect,
|
||||||
Res_StartCopy,
|
Res_StartCopy,
|
||||||
Res_StartVision,
|
Res_StartVision,
|
||||||
@ -37,6 +39,12 @@ from router.v1.place.protocol import (
|
|||||||
Res_UnitList,
|
Res_UnitList,
|
||||||
)
|
)
|
||||||
from router.v1.job.protocol import JobData, Res_Job
|
from router.v1.job.protocol import JobData, Res_Job
|
||||||
|
|
||||||
|
# 공개 검색이 한 번에 가져오는 후보 수. 카카오 키워드 검색은 무료 한도를 넘기면 건당 과금이라
|
||||||
|
# 확정 경로(15건)보다 좁게 잡는다 — 랜딩에서 사람이 훑는 목록은 다섯이면 충분하다.
|
||||||
|
_PUBLIC_SEARCH_SIZE = 5
|
||||||
|
# IP 당 분당 허용 횟수. 사람이 상호명을 고쳐 가며 치는 속도를 넘지 않게 잡았다.
|
||||||
|
_PUBLIC_SEARCH_PER_MIN = 20
|
||||||
# 도로명주소 → 지역 캐시 키. 외부 장소 DB 는 행정구역 코드를 주지 않으므로 여기서 만든다.
|
# 도로명주소 → 지역 캐시 키. 외부 장소 DB 는 행정구역 코드를 주지 않으므로 여기서 만든다.
|
||||||
from services.external.naver import region_key
|
from services.external.naver import region_key
|
||||||
from services.job_service import enqueue_job
|
from services.job_service import enqueue_job
|
||||||
@ -582,6 +590,74 @@ class PlaceService:
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
# ---- 동일 업소 후보 조회 (UI 가 사람에게 고르게 한다) ----
|
# ---- 동일 업소 후보 조회 (UI 가 사람에게 고르게 한다) ----
|
||||||
|
# ---- 공개 상호명 검색 (랜딩 첫 화면) ----
|
||||||
|
async def search_places_public(self, query: str, client_ip: str) -> Res_PlaceSearch:
|
||||||
|
"""상호명으로 외부 장소 DB 를 찾아 그대로 돌려준다. **인증도, 사업장 행도 없다.**
|
||||||
|
|
||||||
|
★ find_candidates 와 무엇이 다른가: 저쪽은 이미 만들어진 사업장의 신원을 확정하는
|
||||||
|
경로라 place_id 와 로그인이 필요하다. 여기는 아직 아무것도 만들지 않은 사람이
|
||||||
|
"내 가게가 있나" 를 보는 경로다 — 만들어 보기도 전에 로그인을 요구하지 않기로 한
|
||||||
|
결정(로그인 관문은 에디터 진입 하나)이 API 까지 내려온 것이다.
|
||||||
|
|
||||||
|
★ pick_match 를 돌리지 않는다. 자동 판정은 확정 경로에서만 의미가 있고,
|
||||||
|
여기서는 사람이 목록에서 고르는 게 전부다.
|
||||||
|
|
||||||
|
★ DB 를 읽지도 쓰지도 않는다. 나가는 값은 외부 장소 DB 가 공개적으로 주는 것뿐이다.
|
||||||
|
"""
|
||||||
|
from common.enums import ExternalPlaceSource
|
||||||
|
from common.utils import rate_limit
|
||||||
|
from services.external import kakao as kakao_client
|
||||||
|
from services.external import naver as naver_client
|
||||||
|
from services.place_category import guess_category
|
||||||
|
|
||||||
|
res = Res_PlaceSearch()
|
||||||
|
q = (query or "").strip()
|
||||||
|
if len(q) < 2:
|
||||||
|
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||||||
|
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):
|
||||||
|
res.result.SetResult(ErrorType.HTTP_TO_MANY_REQUEST)
|
||||||
|
return res
|
||||||
|
|
||||||
|
kakao = kakao_client.KakaoLocalClient()
|
||||||
|
try:
|
||||||
|
if kakao.enabled:
|
||||||
|
res.source = ExternalPlaceSource.KAKAO
|
||||||
|
rows = await kakao.search_keyword(q, size=_PUBLIC_SEARCH_SIZE)
|
||||||
|
await kakao.aclose()
|
||||||
|
else:
|
||||||
|
client = naver_client.NaverLocalClient()
|
||||||
|
if not client.enabled:
|
||||||
|
res.result.SetResult(ErrorType.LOCAL_NOT_CONFIGURED)
|
||||||
|
return res
|
||||||
|
res.source = ExternalPlaceSource.NAVER
|
||||||
|
rows = await client.search_local(q, display=_PUBLIC_SEARCH_SIZE)
|
||||||
|
await client.aclose()
|
||||||
|
except (kakao_client.KakaoNotConfigured, naver_client.NaverNotConfigured):
|
||||||
|
res.result.SetResult(ErrorType.LOCAL_NOT_CONFIGURED)
|
||||||
|
return res
|
||||||
|
except (kakao_client.KakaoRequestFailed, naver_client.NaverRequestFailed) as ex:
|
||||||
|
LOG.w(f"[search] 공개 검색 실패 q={q!r}: {type(ex).__name__}: {ex}")
|
||||||
|
res.result.SetResult(ErrorType.LOCAL_FETCH_FAILED)
|
||||||
|
return res
|
||||||
|
|
||||||
|
res.items = [
|
||||||
|
PlaceSearchItem(
|
||||||
|
name=row.name,
|
||||||
|
road_address=row.road_address or row.address,
|
||||||
|
category_name=row.category_name,
|
||||||
|
# 네이버는 그룹코드를 주지 않는다 — 그때는 분류 문자열만으로 추정한다.
|
||||||
|
category=guess_category(row.category_name, getattr(row, "category_group_code", None)),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
if not res.items:
|
||||||
|
res.result.SetResult(ErrorType.PLACE_VERIFY_NO_CANDIDATE)
|
||||||
|
return res
|
||||||
|
|
||||||
async def find_candidates(self, user_info: UserInfo, place_id: str, query: str | None = None) -> Res_VerifyCandidates:
|
async def find_candidates(self, user_info: UserInfo, place_id: str, query: str | None = None) -> Res_VerifyCandidates:
|
||||||
"""외부 장소 DB 에서 이 상호명의 후보를 찾아 그대로 내려준다.
|
"""외부 장소 DB 에서 이 상호명의 후보를 찾아 그대로 내려준다.
|
||||||
|
|
||||||
|
|||||||
@ -64,7 +64,8 @@ def _client(handler, api_key: str = "test-kakao-key") -> KakaoLocalClient:
|
|||||||
def _place(name, kakao_id="1", phone=None, road="강원 양양군 A로 1") -> KakaoPlace:
|
def _place(name, kakao_id="1", phone=None, road="강원 양양군 A로 1") -> KakaoPlace:
|
||||||
return KakaoPlace(
|
return KakaoPlace(
|
||||||
kakao_place_id=kakao_id, name=name, road_address=road, address=None,
|
kakao_place_id=kakao_id, name=name, road_address=road, address=None,
|
||||||
phone=phone, latitude=38.0, longitude=128.6, category_name=None, place_url=None,
|
phone=phone, latitude=38.0, longitude=128.6, category_name=None,
|
||||||
|
category_group_code=None, place_url=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
120
solution/backend/tests/test_place_search.py
Normal file
120
solution/backend/tests/test_place_search.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"""상호명 공개 검색 — 로그인 없이 열려 있고, 업종을 LLM 없이 외부 분류로 추정한다.
|
||||||
|
|
||||||
|
★ 인증이 없는 유일한 place 경로다. 우리 DB 값이 하나도 나가지 않는지 여기서 고정한다.
|
||||||
|
"""
|
||||||
|
from common.enums import ErrorType, PlaceCategory
|
||||||
|
from common.utils import rate_limit
|
||||||
|
from services.external import kakao as kakao_client
|
||||||
|
from services.external import naver as naver_client
|
||||||
|
from services.place_category import guess_category
|
||||||
|
|
||||||
|
|
||||||
|
def _kp(name, category_name, group_code, kakao_id="1"):
|
||||||
|
return kakao_client.KakaoPlace(
|
||||||
|
kakao_place_id=kakao_id, name=name, road_address="강원 양양군 A로 1", address="양양읍 1-1",
|
||||||
|
phone="033-000-0000", latitude=38.0, longitude=128.6,
|
||||||
|
category_name=category_name, category_group_code=group_code, place_url="http://x",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_kakao(monkeypatch, rows):
|
||||||
|
monkeypatch.setattr(kakao_client.KakaoLocalClient, "enabled", property(lambda self: True))
|
||||||
|
|
||||||
|
async def _search(self, name, x=None, y=None, size=15):
|
||||||
|
return rows
|
||||||
|
|
||||||
|
async def _close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(kakao_client.KakaoLocalClient, "search_keyword", _search)
|
||||||
|
monkeypatch.setattr(kakao_client.KakaoLocalClient, "aclose", _close)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 업종 추정 ────────────────────────────────────────────────────────────
|
||||||
|
def test_group_code_maps_to_category():
|
||||||
|
assert guess_category("가정,생활 > 숙박 > 펜션", "AD5") is PlaceCategory.LODGING
|
||||||
|
assert guess_category("음식점 > 카페", "CE7") is PlaceCategory.CAFE
|
||||||
|
assert guess_category("음식점 > 한식 > 육류,고기", "FD6") is PlaceCategory.RESTAURANT
|
||||||
|
|
||||||
|
|
||||||
|
def test_hospital_needs_the_right_department():
|
||||||
|
"""HP8 은 병원이라는 것까지만 말한다 — 우리가 여는 건 피부과·성형외과뿐이다."""
|
||||||
|
assert guess_category("의료,건강 > 병원 > 피부과", "HP8") is PlaceCategory.CLINIC
|
||||||
|
assert guess_category("의료,건강 > 병원 > 치과", "HP8") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_skin_care_shop_is_not_a_clinic():
|
||||||
|
"""'피부' 로 보면 피부관리실이 병원으로 걸린다 — 의료 광고 규제가 붙는 업종이라 제일 비싸다."""
|
||||||
|
assert guess_category("생활,서비스 > 미용 > 피부관리") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_naver_has_no_group_code():
|
||||||
|
assert guess_category("숙박>게스트하우스") is PlaceCategory.LODGING
|
||||||
|
assert guess_category("음식점>카페,디저트") is PlaceCategory.CAFE
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_category_is_none():
|
||||||
|
"""모르면 None. 억지로 고르지 않는다 — 화면이 사장님에게 직접 묻는다."""
|
||||||
|
assert guess_category(None) is None
|
||||||
|
assert guess_category("스포츠,레저 > 골프장") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 엔드포인트 ───────────────────────────────────────────────────────────
|
||||||
|
async def test_search_needs_no_token(client, monkeypatch):
|
||||||
|
rate_limit.reset()
|
||||||
|
_patch_kakao(monkeypatch, [_kp("하조대펜션", "가정,생활 > 숙박 > 펜션", "AD5")])
|
||||||
|
|
||||||
|
res = await client.get("/v1/place/search", params={"q": "하조대펜션"})
|
||||||
|
|
||||||
|
assert res.status_code == 200
|
||||||
|
body = res.json()
|
||||||
|
assert body["result"]["success"] is True
|
||||||
|
assert body["items"][0]["name"] == "하조대펜션"
|
||||||
|
assert body["items"][0]["category"] == PlaceCategory.LODGING.value
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_leaks_nothing_of_ours(client, monkeypatch):
|
||||||
|
"""공개 응답이라 우리 DB 값이 새면 그대로 밖이다. 좌표·전화도 뺐다."""
|
||||||
|
rate_limit.reset()
|
||||||
|
_patch_kakao(monkeypatch, [_kp("하조대펜션", "가정,생활 > 숙박 > 펜션", "AD5")])
|
||||||
|
|
||||||
|
item = (await client.get("/v1/place/search", params={"q": "하조대펜션"})).json()["items"][0]
|
||||||
|
|
||||||
|
for leaked in ("place_id", "company_id", "owner_user_id", "phone", "latitude", "longitude",
|
||||||
|
"external_place_id", "address"):
|
||||||
|
assert leaked not in item, f"{leaked} 가 공개 응답에 나갔다"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_does_not_create_a_place(client, monkeypatch, auth_headers):
|
||||||
|
"""검색만으로 사업장이 생기면 안 된다 — 랜딩 트래픽이 그대로 DB 가 된다."""
|
||||||
|
rate_limit.reset()
|
||||||
|
_patch_kakao(monkeypatch, [_kp("하조대펜션", "가정,생활 > 숙박 > 펜션", "AD5")])
|
||||||
|
h = await auth_headers("u1")
|
||||||
|
|
||||||
|
before = (await client.get("/v1/place/list", headers=h)).json()["total"]
|
||||||
|
await client.get("/v1/place/search", params={"q": "하조대펜션"})
|
||||||
|
after = (await client.get("/v1/place/list", headers=h)).json()["total"]
|
||||||
|
|
||||||
|
assert before == after
|
||||||
|
|
||||||
|
|
||||||
|
async def test_search_rejects_short_query(client):
|
||||||
|
rate_limit.reset()
|
||||||
|
res = await client.get("/v1/place/search", params={"q": "가"})
|
||||||
|
assert res.status_code == 422 # min_length=2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rate_limit_closes_the_tap(client, monkeypatch):
|
||||||
|
"""인증이 없는데 유료 외부 API 를 부른다 — 새로고침만으로 요금이 나가면 안 된다."""
|
||||||
|
rate_limit.reset()
|
||||||
|
_patch_kakao(monkeypatch, [_kp("하조대펜션", "가정,생활 > 숙박 > 펜션", "AD5")])
|
||||||
|
|
||||||
|
from services import place_service
|
||||||
|
|
||||||
|
monkeypatch.setattr(place_service, "_PUBLIC_SEARCH_PER_MIN", 2)
|
||||||
|
for _ in range(2):
|
||||||
|
assert (await client.get("/v1/place/search", params={"q": "하조대펜션"})).json()["result"]["success"] is True
|
||||||
|
|
||||||
|
blocked = (await client.get("/v1/place/search", params={"q": "하조대펜션"})).json()
|
||||||
|
assert blocked["result"]["success"] is False
|
||||||
|
assert blocked["result"]["code"] == ErrorType.HTTP_TO_MANY_REQUEST.value
|
||||||
Loading…
Reference in New Issue
Block a user