import json from typing import Any import httpx from pydantic import ValidationError from app.core.config import settings from app.core.tianditu_logging import install_tianditu_token_filter from app.domain.schemas.place_search import ( MAX_PLACE_SEARCH_RESULTS, PlaceSearchItem, PlaceSearchRequest, PlaceSearchResponse, ) FENGXIAN_ADMIN_CODE = "156310120" install_tianditu_token_filter() class PlaceSearchError(RuntimeError): def __init__(self, status_code: int, detail: str): super().__init__(detail) self.status_code = status_code self.detail = detail async def search_tianditu_places( request: PlaceSearchRequest, *, client: httpx.AsyncClient | None = None, ) -> PlaceSearchResponse: token = settings.TIANDITU_TOKEN if not token: raise PlaceSearchError(503, "地名搜索服务未配置") params = { "postStr": json.dumps( { "keyWord": request.keyword, "queryType": 12, "specify": FENGXIAN_ADMIN_CODE, "start": 0, "count": MAX_PLACE_SEARCH_RESULTS, "show": 2, }, ensure_ascii=False, ), "type": "query", "tk": token, } try: if client is not None: response = await client.get(settings.TIANDITU_SEARCH_URL, params=params) else: async with httpx.AsyncClient( timeout=settings.TIANDITU_SEARCH_TIMEOUT_SECONDS ) as managed_client: response = await managed_client.get( settings.TIANDITU_SEARCH_URL, params=params ) response.raise_for_status() except httpx.TimeoutException: raise PlaceSearchError(504, "地名搜索超时,请稍后重试") from None except httpx.HTTPError: # Request URLs contain the provider key and must not reach the client. raise PlaceSearchError(502, "天地图地名搜索服务暂不可用") from None try: return _parse_response(response.json()) except (ValueError, TypeError, KeyError, AttributeError, ValidationError): raise PlaceSearchError(502, "天地图返回了无效的地名搜索结果") from None def _parse_response(payload: dict[str, Any]) -> PlaceSearchResponse: code = str(payload.get("status", {}).get("infocode", "")) if code == "3001": return PlaceSearchResponse(items=[], total=0) if code != "1000": raise PlaceSearchError(502, "天地图地名搜索请求失败,请稍后重试") result_type = str(payload.get("resultType", "")) if result_type not in {"1", "3"}: raise PlaceSearchError(502, "未获得可定位的地名结果,请细化关键字") raw_items = payload.get("pois", []) if result_type == "1" else payload.get("area", []) if not isinstance(raw_items, list) or len(raw_items) > MAX_PLACE_SEARCH_RESULTS: raise ValueError("Invalid search results") items = [] for raw in raw_items: admin_code = str(raw.get("provinceCode") or raw.get("adminCode") or "") if admin_code and not admin_code.startswith(("156310", "310")): raise ValueError("Result outside Shanghai") county_code = str(raw.get("countyCode") or (raw.get("adminCode") if result_type == "3" else "") or "") county = raw.get("county") if county_code and not county_code.startswith((FENGXIAN_ADMIN_CODE, "310120")): raise ValueError("Result outside Fengxian") if county and county != "奉贤区" or not county_code and county != "奉贤区": raise ValueError("Missing or mismatched district") lon, lat = str(raw["lonlat"]).split(",") name = raw["name"] if not isinstance(name, str) or not name.strip(): raise ValueError("Missing place name") name = name.strip() category = raw.get("typeName") or ("公交站" if str(raw.get("poiType")) == "102" else None) items.append(PlaceSearchItem( id=str(raw.get("hotPointID") or raw.get("adminCode") or f"{name}:{lon},{lat}"), name=name, address=str(raw.get("address") or name), longitude=float(lon), latitude=float(lat), category="行政区" if result_type == "3" else category, district="奉贤区", )) total = int(payload.get("count", 0)) if total < len(items): raise ValueError("Invalid search total") return PlaceSearchResponse( items=items, total=len(items), truncated=total > MAX_PLACE_SEARCH_RESULTS, )