138 lines
5.4 KiB
Python
138 lines
5.4 KiB
Python
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,
|
|
)
|