Add batch management APIs, API security, rate limiting, and optimizations
- Batch device CRUD: POST /api/devices/batch (create 500), PUT /api/devices/batch (update 500), POST /api/devices/batch-delete (delete 100) with WHERE IN bulk queries - Batch command: POST /api/commands/batch with model_validator mutual exclusion - API key auth (X-API-Key header, secrets.compare_digest timing-safe) - Rate limiting via SlowAPIMiddleware (60/min default, 30/min writes) - Real client IP extraction (X-Forwarded-For / CF-Connecting-IP) - Global exception handler (no stack trace leaks, passes HTTPException through) - CORS with auto-disable credentials on wildcard origins - Schema validation: IMEI pattern, lat/lon ranges, Literal enums, MAC/UUID patterns - Heartbeats router, per-ID endpoints for locations/attendance/bluetooth - Input dedup in batch create, result ordering preserved - Baidu reverse geocoding, Gaode map tiles with WGS84→GCJ02 conversion - Device detail panel with feature toggles and command controls - Side panel for location/beacon pages with auto-select active device via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
@@ -10,7 +10,6 @@ Uses free APIs:
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
@@ -19,19 +18,16 @@ import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Google Geolocation API key (set to enable Google geocoding)
|
||||
GOOGLE_API_KEY: Optional[str] = None
|
||||
# Import keys from centralized config (no more hardcoded values here)
|
||||
from app.config import settings as _settings
|
||||
|
||||
# Unwired Labs API token (free tier: 100 requests/day)
|
||||
# Sign up at https://unwiredlabs.com/
|
||||
UNWIRED_API_TOKEN: Optional[str] = None
|
||||
GOOGLE_API_KEY: Optional[str] = _settings.GOOGLE_API_KEY
|
||||
UNWIRED_API_TOKEN: Optional[str] = _settings.UNWIRED_API_TOKEN
|
||||
TIANDITU_API_KEY: Optional[str] = _settings.TIANDITU_API_KEY
|
||||
BAIDU_MAP_AK: Optional[str] = _settings.BAIDU_MAP_AK
|
||||
|
||||
# 天地图 API key (free tier: 10000 requests/day)
|
||||
# Sign up at https://lbs.tianditu.gov.cn/
|
||||
TIANDITU_API_KEY: Optional[str] = os.environ.get("TIANDITU_API_KEY", "439fca3920a6f31584014424f89c3ecc")
|
||||
|
||||
# Maximum cache entries (LRU eviction)
|
||||
_CACHE_MAX_SIZE = 10000
|
||||
# Maximum cache entries (LRU eviction) — configurable via settings
|
||||
_CACHE_MAX_SIZE = _settings.GEOCODING_CACHE_SIZE
|
||||
|
||||
|
||||
class LRUCache(OrderedDict):
|
||||
@@ -315,7 +311,8 @@ async def reverse_geocode(
|
||||
"""
|
||||
Convert lat/lon to a human-readable address.
|
||||
|
||||
Tries 天地图 (Tianditu) first, which uses WGS84 natively.
|
||||
Priority: Baidu Map > Tianditu (fallback).
|
||||
Both accept WGS84 coordinates natively (Baidu via coordtype=wgs84ll).
|
||||
Returns None if no reverse geocoding service is available.
|
||||
"""
|
||||
# Round to ~100m for cache key to reduce API calls
|
||||
@@ -324,6 +321,14 @@ async def reverse_geocode(
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Try Baidu Map first (higher quality addresses for China)
|
||||
if BAIDU_MAP_AK:
|
||||
result = await _reverse_geocode_baidu(lat, lon)
|
||||
if result:
|
||||
_address_cache.put(cache_key, result)
|
||||
return result
|
||||
|
||||
# Fallback to Tianditu
|
||||
if TIANDITU_API_KEY:
|
||||
result = await _reverse_geocode_tianditu(lat, lon)
|
||||
if result:
|
||||
@@ -333,6 +338,55 @@ async def reverse_geocode(
|
||||
return None
|
||||
|
||||
|
||||
async def _reverse_geocode_baidu(
|
||||
lat: float, lon: float
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Use Baidu Map reverse geocoding API.
|
||||
|
||||
API docs: https://lbsyun.baidu.com/faq/api?title=webapi/guide/webservice-geocoding
|
||||
Input coordtype: wgs84ll (WGS-84, same as GPS data, no conversion needed).
|
||||
Free tier: 5,000 requests/day (personal developer).
|
||||
"""
|
||||
url = (
|
||||
f"https://api.map.baidu.com/reverse_geocoding/v3/"
|
||||
f"?ak={BAIDU_MAP_AK}&output=json&coordtype=wgs84ll"
|
||||
f"&location={lat},{lon}"
|
||||
)
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
url, timeout=aiohttp.ClientTimeout(total=5)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json(content_type=None)
|
||||
if data.get("status") == 0:
|
||||
result = data.get("result", {})
|
||||
formatted = result.get("formatted_address", "")
|
||||
if formatted:
|
||||
# Add sematic_description for more context
|
||||
sematic = result.get("sematic_description", "")
|
||||
address = formatted
|
||||
if sematic and sematic not in formatted:
|
||||
address = f"{formatted} ({sematic})"
|
||||
logger.info(
|
||||
"Baidu reverse geocode: %.6f,%.6f -> %s",
|
||||
lat, lon, address,
|
||||
)
|
||||
return address
|
||||
else:
|
||||
logger.warning(
|
||||
"Baidu reverse geocode error: status=%s, msg=%s",
|
||||
data.get("status"), data.get("message", ""),
|
||||
)
|
||||
else:
|
||||
logger.warning("Baidu reverse geocode HTTP %d", resp.status)
|
||||
except Exception as e:
|
||||
logger.warning("Baidu reverse geocode error: %s", e)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _reverse_geocode_tianditu(
|
||||
lat: float, lon: float
|
||||
) -> Optional[str]:
|
||||
|
||||
Reference in New Issue
Block a user