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:
2026-03-20 09:18:43 +00:00
parent 1bdbe4fa19
commit 7d6040af41
23 changed files with 1564 additions and 294 deletions

View File

@@ -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]: