feat(api): add Tianditu geocoding
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user