import json from typing import Any import httpx from app.core.config import settings from app.domain.schemas.place_search import PlaceSearchItem, ReverseGeocodingRequest from app.domain.schemas.project_map_config import PlaceSearchConfig from app.services.place_search import PlaceSearchError, PlaceSearchScopeError, validate_place_scope async def reverse_geocode_tianditu( request: ReverseGeocodingRequest, *, config: PlaceSearchConfig, client: httpx.AsyncClient | None = None, ) -> PlaceSearchItem: if not config.enabled: raise PlaceSearchError(503, "当前项目暂未配置地名搜索") if not settings.TIANDITU_TOKEN: raise PlaceSearchError(503, "地名搜索服务未配置") params = { "postStr": json.dumps({"lon": request.longitude, "lat": request.latitude, "ver": 1}), "type": "geocode", "tk": settings.TIANDITU_TOKEN, } try: if client is not None: response = await client.get(settings.TIANDITU_GEOCODER_URL, params=params) else: async with httpx.AsyncClient(timeout=settings.TIANDITU_SEARCH_TIMEOUT_SECONDS) as managed: response = await managed.get(settings.TIANDITU_GEOCODER_URL, params=params) response.raise_for_status() except httpx.TimeoutException: raise PlaceSearchError(504, "地图取点查询超时,请重新取点") from None except httpx.HTTPError: raise PlaceSearchError(502, "天地图反编码服务暂不可用,请重新取点") from None try: return _parse_reverse_response(response.json(), request, config) except PlaceSearchScopeError: raise PlaceSearchError(422, f"取点位置不在当前项目的地名搜索范围内({config.region})") from None except (ValueError, TypeError, KeyError, AttributeError): raise PlaceSearchError(502, "天地图返回了无效的反编码结果,请重新取点") from None def _parse_reverse_response( payload: dict[str, Any], request: ReverseGeocodingRequest, config: PlaceSearchConfig, ) -> PlaceSearchItem: if str(payload.get("status")) != "0": raise PlaceSearchError(502, "天地图反编码请求失败,请重新取点") result = payload.get("result") if not result: raise PlaceSearchError(404, "该位置未找到地名或地址,请重新取点") address = result.get("formatted_address") if not isinstance(address, str) or not address.strip(): raise PlaceSearchError(404, "该位置未找到地名或地址,请重新取点") component = result.get("addressComponent") or {} validate_place_scope( config, county_code=str(component.get("county_code") or ""), county=str(component.get("county") or ""), province_code=str(component.get("province_code") or ""), ) name = component.get("poi") or component.get("road") or address if not isinstance(name, str) or not name.strip(): raise ValueError("Invalid place name") # A nearby POI can have a different center. Always retain the clicked point. return PlaceSearchItem( id=f"map:{request.longitude:.8f},{request.latitude:.8f}", name=name.strip(), address=address.strip(), longitude=request.longitude, latitude=request.latitude, category="附近地名" if component.get("poi") else "地址", district=str(component.get("county") or config.region), )