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.project_map_config import PlaceSearchConfig from app.domain.schemas.place_search import ( MAX_PLACE_SEARCH_RESULTS, PlaceSearchItem, PlaceSearchRequest, PlaceSearchResponse, ) 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 class PlaceSearchScopeError(ValueError): pass def validate_place_scope( config: PlaceSearchConfig, *, county_code: str, county: str, province_code: str = "", ) -> None: admin_code = config.admin_code.removeprefix("156") province_code = province_code.removeprefix("156") county_code = county_code.removeprefix("156") if province_code and province_code[:2] != admin_code[:2]: raise PlaceSearchScopeError("Result outside configured province") scope_prefix = admin_code if admin_code.endswith("0000"): scope_prefix = admin_code[:2] elif admin_code.endswith("00"): scope_prefix = admin_code[:4] if county_code and not county_code.startswith(scope_prefix): raise PlaceSearchScopeError("Result outside configured district") if (len(scope_prefix) == 6 and county and county != config.region) or (not county_code and county != config.region): raise PlaceSearchScopeError("Missing or mismatched district") async def search_tianditu_places( request: PlaceSearchRequest, *, config: PlaceSearchConfig, client: httpx.AsyncClient | None = None, ) -> PlaceSearchResponse: if not config.enabled: raise PlaceSearchError(503, "当前项目暂未配置地名搜索") token = settings.TIANDITU_TOKEN if not token: raise PlaceSearchError(503, "地名搜索服务未配置") params = { "postStr": json.dumps( { "keyWord": request.keyword, "queryType": 12, "specify": "156" + config.admin_code.removeprefix("156"), "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(), config) except (ValueError, TypeError, KeyError, AttributeError, ValidationError): raise PlaceSearchError(502, "天地图返回了无效的地名搜索结果") from None def _parse_response(payload: dict[str, Any], config: PlaceSearchConfig) -> PlaceSearchResponse: code = str(payload.get("status", {}).get("infocode", "")) if code == "3001": return PlaceSearchResponse(items=[], total=0, region=config.region) 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: county = raw.get("county") validate_place_scope( config, county_code=str(raw.get("countyCode") or raw.get("adminCode") or ""), county=str(county or ""), province_code=str(raw.get("provinceCode") or ""), ) 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=str(county or config.region), )) 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, region=config.region, )