diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index a7ab026..05e2b53 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -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 — 발행하면 썸네일이 남는다 (랜딩 쇼케이스용) **왜** diff --git a/solution/backend/common/utils/rate_limit.py b/solution/backend/common/utils/rate_limit.py new file mode 100644 index 0000000..b4e3ad6 --- /dev/null +++ b/solution/backend/common/utils/rate_limit.py @@ -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() diff --git a/solution/backend/router/v1/place/place.py b/solution/backend/router/v1/place/place.py index 4c725c1..3b67e4d 100644 --- a/solution/backend/router/v1/place/place.py +++ b/solution/backend/router/v1/place/place.py @@ -1,7 +1,7 @@ from typing import Optional 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.models.gmodel import PageParams, UserInfo @@ -22,6 +22,7 @@ from .protocol import ( Res_LinkList, Res_Place, Res_PlaceList, + Res_PlaceSearch, Res_StartCollect, Res_StartCopy, Res_StartVision, @@ -46,6 +47,26 @@ async def list_places( 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( path="", response_model=Res_Place, diff --git a/solution/backend/router/v1/place/protocol.py b/solution/backend/router/v1/place/protocol.py index e74b4f5..e9edf4e 100644 --- a/solution/backend/router/v1/place/protocol.py +++ b/solution/backend/router/v1/place/protocol.py @@ -208,6 +208,33 @@ class Res_VerifyCandidates(Res_WebPacketProtocol): 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): """소개문·FAQ 생성 시작. diff --git a/solution/backend/services/external/kakao.py b/solution/backend/services/external/kakao.py index d5dc4e9..160d98e 100644 --- a/solution/backend/services/external/kakao.py +++ b/solution/backend/services/external/kakao.py @@ -97,6 +97,9 @@ class KakaoPlace: latitude: Optional[float] # y longitude: Optional[float] # x category_name: Optional[str] + # ★ 업종 자동 판별의 입력. 한글 분류(category_name)는 카카오가 언제든 바꾸지만 이 코드는 안 바뀐다 — + # AD5 숙박 · CE7 카페 · FD6 음식점 · HP8 병원. 매핑은 services/place_category.py 가 소유한다. + category_group_code: Optional[str] place_url: Optional[str] @classmethod @@ -110,6 +113,7 @@ class KakaoPlace: latitude=_to_float(doc.get("y")), # ★ y = 위도 longitude=_to_float(doc.get("x")), # ★ x = 경도 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), ) diff --git a/solution/backend/services/place_category.py b/solution/backend/services/place_category.py new file mode 100644 index 0000000..950b662 --- /dev/null +++ b/solution/backend/services/place_category.py @@ -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 diff --git a/solution/backend/services/place_service.py b/solution/backend/services/place_service.py index 31afcd8..385fde5 100644 --- a/solution/backend/services/place_service.py +++ b/solution/backend/services/place_service.py @@ -28,8 +28,10 @@ from router.v1.place.protocol import ( Res_LinkList, Res_Place, Res_PlaceList, + Res_PlaceSearch, Res_VerifyCandidates, PlaceCandidate, + PlaceSearchItem, Res_StartCollect, Res_StartCopy, Res_StartVision, @@ -37,6 +39,12 @@ from router.v1.place.protocol import ( Res_UnitList, ) from router.v1.job.protocol import JobData, Res_Job + +# 공개 검색이 한 번에 가져오는 후보 수. 카카오 키워드 검색은 무료 한도를 넘기면 건당 과금이라 +# 확정 경로(15건)보다 좁게 잡는다 — 랜딩에서 사람이 훑는 목록은 다섯이면 충분하다. +_PUBLIC_SEARCH_SIZE = 5 +# IP 당 분당 허용 횟수. 사람이 상호명을 고쳐 가며 치는 속도를 넘지 않게 잡았다. +_PUBLIC_SEARCH_PER_MIN = 20 # 도로명주소 → 지역 캐시 키. 외부 장소 DB 는 행정구역 코드를 주지 않으므로 여기서 만든다. from services.external.naver import region_key from services.job_service import enqueue_job @@ -582,6 +590,74 @@ class PlaceService: return res # ---- 동일 업소 후보 조회 (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: """외부 장소 DB 에서 이 상호명의 후보를 찾아 그대로 내려준다. diff --git a/solution/backend/tests/test_kakao.py b/solution/backend/tests/test_kakao.py index c016234..96b516f 100644 --- a/solution/backend/tests/test_kakao.py +++ b/solution/backend/tests/test_kakao.py @@ -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: return KakaoPlace( 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, ) diff --git a/solution/backend/tests/test_place_search.py b/solution/backend/tests/test_place_search.py new file mode 100644 index 0000000..5e140f7 --- /dev/null +++ b/solution/backend/tests/test_place_search.py @@ -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