From e588d1cf33829bb7bf31f22d4c392c323d211f82 Mon Sep 17 00:00:00 2001 From: Jiang Date: Tue, 9 Jun 2026 17:09:42 +0800 Subject: [PATCH] feat(api): add Tianditu geocoding --- .env.example | 7 ++ app/api/v1/endpoints/geocoding.py | 29 +++++++ app/api/v1/router.py | 2 + app/core/config.py | 5 ++ app/services/geocoding.py | 76 ++++++++++++++++ tests/unit/test_geocoding.py | 140 ++++++++++++++++++++++++++++++ 6 files changed, 259 insertions(+) create mode 100644 app/api/v1/endpoints/geocoding.py create mode 100644 app/services/geocoding.py create mode 100644 tests/unit/test_geocoding.py diff --git a/.env.example b/.env.example index 54c973d..34a15ba 100644 --- a/.env.example +++ b/.env.example @@ -56,3 +56,10 @@ KEYCLOAK_AUDIENCE="account" BOCHA_API_KEY="sk-your-bocha-api-key" BOCHA_WEB_SEARCH_URL="https://api.bochaai.com/v1/web-search" BOCHA_WEB_SEARCH_TIMEOUT_SECONDS=30 + +# ============================================ +# Tianditu Geocoding API +# ============================================ +TIANDITU_GEOCODER_TOKEN="your-tianditu-geocoder-token" +TIANDITU_GEOCODER_URL="https://api.tianditu.gov.cn/geocoder" +TIANDITU_GEOCODER_TIMEOUT_SECONDS=30 diff --git a/app/api/v1/endpoints/geocoding.py b/app/api/v1/endpoints/geocoding.py new file mode 100644 index 0000000..24c6797 --- /dev/null +++ b/app/api/v1/endpoints/geocoding.py @@ -0,0 +1,29 @@ +from typing import Any + +from fastapi import APIRouter, HTTPException, status + +from app.services.geocoding import ( + TiandituGeocodeRequest, + TiandituGeocodingAPIError, + TiandituGeocodingConfigError, + geocode_tianditu, +) + +router = APIRouter() + + +@router.post( + "/tianditu/geocode", + summary="Tianditu Geocoding", + description="调用天地图地理编码服务,将结构化地址转换为经纬度", +) +async def tianditu_geocode(request: TiandituGeocodeRequest) -> dict[str, Any]: + try: + return await geocode_tianditu(request) + except TiandituGeocodingConfigError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(exc), + ) from exc + except TiandituGeocodingAPIError as exc: + raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 4e018cf..52c5431 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -19,6 +19,7 @@ from app.api.v1.endpoints import ( audit, # 新增:审计日志 meta, web_search, + geocoding, ) from app.api.v1.endpoints.network import ( general, @@ -95,6 +96,7 @@ api_router.include_router(misc.router, tags=["Misc"]) api_router.include_router(risk.router, tags=["Risk"]) api_router.include_router(cache.router, tags=["Cache"]) api_router.include_router(web_search.router, tags=["Web Search"]) +api_router.include_router(geocoding.router, tags=["Geocoding"]) api_router.include_router(leakage.router, prefix="/leakage", tags=["Leakage"]) api_router.include_router( burst_detection.router, prefix="/burst-detection", tags=["Burst Detection"] diff --git a/app/core/config.py b/app/core/config.py index 49ccdaf..791d470 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -69,6 +69,11 @@ class Settings(BaseSettings): BOCHA_WEB_SEARCH_URL: str = "https://api.bochaai.com/v1/web-search" BOCHA_WEB_SEARCH_TIMEOUT_SECONDS: float = 30.0 + # Tianditu Geocoding API + TIANDITU_GEOCODER_TOKEN: str = "" + TIANDITU_GEOCODER_URL: str = "https://api.tianditu.gov.cn/geocoder" + TIANDITU_GEOCODER_TIMEOUT_SECONDS: float = 30.0 + @property def SQLALCHEMY_DATABASE_URI(self) -> str: db_password = quote_plus(self.DB_PASSWORD) diff --git a/app/services/geocoding.py b/app/services/geocoding.py new file mode 100644 index 0000000..1fa7eab --- /dev/null +++ b/app/services/geocoding.py @@ -0,0 +1,76 @@ +import json +from typing import Any + +import httpx +from pydantic import AliasChoices, BaseModel, Field + +from app.core.config import settings + + +class TiandituGeocodeRequest(BaseModel): + keyword: str = Field( + ..., + min_length=1, + validation_alias=AliasChoices("keyword", "keyWord"), + description="地理编码地址关键字", + ) + + +class TiandituGeocodingConfigError(RuntimeError): + pass + + +class TiandituGeocodingAPIError(RuntimeError): + def __init__(self, status_code: int, detail: Any): + super().__init__("Tianditu Geocoding API request failed") + self.status_code = status_code + self.detail = detail + + +async def geocode_tianditu( + request: TiandituGeocodeRequest, + *, + client: httpx.AsyncClient | None = None, +) -> dict[str, Any]: + if not settings.TIANDITU_GEOCODER_TOKEN: + raise TiandituGeocodingConfigError("TIANDITU_GEOCODER_TOKEN is not configured") + + params = { + "ds": json.dumps({"keyWord": request.keyword}, ensure_ascii=False), + "tk": settings.TIANDITU_GEOCODER_TOKEN, + } + + if client is not None: + response = await client.get(settings.TIANDITU_GEOCODER_URL, params=params) + return _parse_response(response) + + async with httpx.AsyncClient( + timeout=settings.TIANDITU_GEOCODER_TIMEOUT_SECONDS + ) as managed_client: + response = await managed_client.get( + settings.TIANDITU_GEOCODER_URL, + params=params, + ) + return _parse_response(response) + + +def _parse_response(response: httpx.Response) -> dict[str, Any]: + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise TiandituGeocodingAPIError( + exc.response.status_code, + _response_detail(exc.response), + ) from exc + + data = response.json() + if str(data.get("status")) != "0": + raise TiandituGeocodingAPIError(502, data) + return data + + +def _response_detail(response: httpx.Response) -> Any: + try: + return response.json() + except ValueError: + return response.text diff --git a/tests/unit/test_geocoding.py b/tests/unit/test_geocoding.py new file mode 100644 index 0000000..9f4366b --- /dev/null +++ b/tests/unit/test_geocoding.py @@ -0,0 +1,140 @@ +import asyncio +import importlib.util +import json +from pathlib import Path + +import httpx +import pytest + + +def _load_geocoding_module(): + module_path = Path(__file__).resolve().parents[2] / "app" / "services" / "geocoding.py" + spec = importlib.util.spec_from_file_location("tests_geocoding_under_test", module_path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +geocoding = _load_geocoding_module() + + +class FakeClient: + def __init__(self, response): + self.response = response + self.calls = [] + + async def get(self, url, *, params): + self.calls.append({"url": url, "params": params}) + return self.response + + +def test_geocode_tianditu_gets_expected_params(monkeypatch): + monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test") + monkeypatch.setattr( + geocoding.settings, + "TIANDITU_GEOCODER_URL", + "https://api.tianditu.gov.cn/geocoder", + ) + response = httpx.Response( + 200, + json={ + "location": {"lon": "116.407526", "lat": "39.904030", "level": "地名地址"}, + "status": "0", + "msg": "ok", + }, + request=httpx.Request("GET", "https://api.tianditu.gov.cn/geocoder"), + ) + client = FakeClient(response) + + result = asyncio.run( + geocoding.geocode_tianditu( + geocoding.TiandituGeocodeRequest(keyword="北京市人民政府"), + client=client, + ) + ) + + assert result["location"] == { + "lon": "116.407526", + "lat": "39.904030", + "level": "地名地址", + } + assert client.calls == [ + { + "url": "https://api.tianditu.gov.cn/geocoder", + "params": { + "ds": json.dumps({"keyWord": "北京市人民政府"}, ensure_ascii=False), + "tk": "tk-test", + }, + } + ] + + +def test_geocode_tianditu_accepts_key_word_alias(monkeypatch): + monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test") + response = httpx.Response( + 200, + json={"location": {"lon": "116", "lat": "39"}, "status": "0", "msg": "ok"}, + request=httpx.Request("GET", "https://api.tianditu.gov.cn/geocoder"), + ) + + result = asyncio.run( + geocoding.geocode_tianditu( + geocoding.TiandituGeocodeRequest(keyWord="北京市人民政府"), + client=FakeClient(response), + ) + ) + + assert result["status"] == "0" + + +def test_geocode_tianditu_requires_token(monkeypatch): + monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "") + + with pytest.raises(geocoding.TiandituGeocodingConfigError): + asyncio.run( + geocoding.geocode_tianditu( + geocoding.TiandituGeocodeRequest(keyword="北京市人民政府"), + client=FakeClient(httpx.Response(200, json={})), + ) + ) + + +def test_geocode_tianditu_surfaces_http_error(monkeypatch): + monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test") + response = httpx.Response( + 403, + json={"msg": "invalid tk"}, + request=httpx.Request("GET", "https://api.tianditu.gov.cn/geocoder"), + ) + + with pytest.raises(geocoding.TiandituGeocodingAPIError) as exc_info: + asyncio.run( + geocoding.geocode_tianditu( + geocoding.TiandituGeocodeRequest(keyword="北京市人民政府"), + client=FakeClient(response), + ) + ) + + assert exc_info.value.status_code == 403 + assert exc_info.value.detail == {"msg": "invalid tk"} + + +def test_geocode_tianditu_surfaces_tianditu_error_status(monkeypatch): + monkeypatch.setattr(geocoding.settings, "TIANDITU_GEOCODER_TOKEN", "tk-test") + response = httpx.Response( + 200, + json={"status": "100", "msg": "bad request"}, + request=httpx.Request("GET", "https://api.tianditu.gov.cn/geocoder"), + ) + + with pytest.raises(geocoding.TiandituGeocodingAPIError) as exc_info: + asyncio.run( + geocoding.geocode_tianditu( + geocoding.TiandituGeocodeRequest(keyword="北京市人民政府"), + client=FakeClient(response), + ) + ) + + assert exc_info.value.status_code == 502 + assert exc_info.value.detail == {"status": "100", "msg": "bad request"}