feat: 性能优化 + 设备总览轨迹展示 + 广播指令API

性能: SQLite WAL模式、aiohttp Session复用、TCP连接锁+空闲超时、
device_id缓存、WebSocket并发广播、API Key认证缓存、围栏N+1查询
批量化、逆地理编码并行化、新增5个DB索引、日志降级DEBUG

功能: 广播指令API(broadcast)、exclude_type低精度后端过滤、
前端设备总览Tab+多设备轨迹叠加+高亮联动+搜索+专属颜色

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-03-31 09:41:09 +00:00
parent b970b78136
commit b25eafc483
12 changed files with 1030 additions and 244 deletions

View File

@@ -27,6 +27,31 @@ AMAP_HARDWARE_SECRET: Optional[str] = _settings.AMAP_HARDWARE_SECRET
_CACHE_MAX_SIZE = _settings.GEOCODING_CACHE_SIZE
# ---------------------------------------------------------------------------
# Shared aiohttp session (reused across all geocoding calls)
# ---------------------------------------------------------------------------
_http_session: Optional[aiohttp.ClientSession] = None
async def _get_http_session() -> aiohttp.ClientSession:
"""Get or create the shared aiohttp session (lazy init)."""
global _http_session
if _http_session is None or _http_session.closed:
_http_session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=5),
)
return _http_session
async def close_http_session() -> None:
"""Close the shared session (call on app shutdown)."""
global _http_session
if _http_session and not _http_session.closed:
await _http_session.close()
_http_session = None
# ---------------------------------------------------------------------------
# WGS-84 → GCJ-02 coordinate conversion (server-side)
# ---------------------------------------------------------------------------
@@ -316,38 +341,36 @@ async def _geocode_amap_v5(
url = f"https://restapi.amap.com/v5/position/IoT?key={api_key}"
logger.info("Amap v5 request body: %s", body)
logger.debug("Amap v5 request body: %s", body)
try:
async with aiohttp.ClientSession() as session:
async with session.post(
url, data=body, timeout=aiohttp.ClientTimeout(total=5)
) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
logger.info("Amap v5 response: %s", data)
if data.get("status") == "1":
position = data.get("position", {})
location = position.get("location", "") if isinstance(position, dict) else ""
if location and "," in location:
lon_str, lat_str = location.split(",")
gcj_lat = float(lat_str)
gcj_lon = float(lon_str)
lat, lon = gcj02_to_wgs84(gcj_lat, gcj_lon)
radius = position.get("radius", "?") if isinstance(position, dict) else "?"
logger.info(
"Amap v5 geocode: GCJ-02(%.6f,%.6f) -> WGS-84(%.6f,%.6f) radius=%s",
gcj_lat, gcj_lon, lat, lon, radius,
)
return (lat, lon)
else:
infocode = data.get("infocode", "")
logger.warning(
"Amap v5 geocode error: %s (code=%s) body=%s",
data.get("info", ""), infocode, body,
session = await _get_http_session()
async with session.post(url, data=body) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
logger.debug("Amap v5 response: %s", data)
if data.get("status") == "1":
position = data.get("position", {})
location = position.get("location", "") if isinstance(position, dict) else ""
if location and "," in location:
lon_str, lat_str = location.split(",")
gcj_lat = float(lat_str)
gcj_lon = float(lon_str)
lat, lon = gcj02_to_wgs84(gcj_lat, gcj_lon)
radius = position.get("radius", "?") if isinstance(position, dict) else "?"
logger.info(
"Amap v5 geocode: GCJ-02(%.6f,%.6f) -> WGS-84(%.6f,%.6f) radius=%s",
gcj_lat, gcj_lon, lat, lon, radius,
)
return (lat, lon)
else:
logger.warning("Amap v5 geocode HTTP %d", resp.status)
infocode = data.get("infocode", "")
logger.warning(
"Amap v5 geocode error: %s (code=%s) body=%s",
data.get("info", ""), infocode, body,
)
else:
logger.warning("Amap v5 geocode HTTP %d", resp.status)
except Exception as e:
logger.warning("Amap v5 geocode error: %s", e)
@@ -400,37 +423,35 @@ async def _geocode_amap_legacy(
url = "https://apilocate.amap.com/position"
logger.info("Amap legacy request params: %s", {k: v for k, v in params.items() if k != 'key'})
logger.debug("Amap legacy request params: %s", {k: v for k, v in params.items() if k != 'key'})
try:
async with aiohttp.ClientSession() as session:
async with session.get(
url, params=params, timeout=aiohttp.ClientTimeout(total=5)
) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
logger.info("Amap legacy response: %s", data)
if data.get("status") == "1" and data.get("result"):
result = data["result"]
location = result.get("location", "")
if location and "," in location:
lon_str, lat_str = location.split(",")
gcj_lat = float(lat_str)
gcj_lon = float(lon_str)
lat, lon = gcj02_to_wgs84(gcj_lat, gcj_lon)
logger.info(
"Amap legacy geocode: GCJ-02(%.6f,%.6f) -> WGS-84(%.6f,%.6f)",
gcj_lat, gcj_lon, lat, lon,
)
return (lat, lon)
else:
infocode = data.get("infocode", "")
if infocode == "10012":
logger.debug("Amap legacy geocode: insufficient permissions (enterprise cert needed)")
else:
logger.warning("Amap legacy geocode error: %s (code=%s)", data.get("info", ""), infocode)
session = await _get_http_session()
async with session.get(url, params=params) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
logger.debug("Amap legacy response: %s", data)
if data.get("status") == "1" and data.get("result"):
result = data["result"]
location = result.get("location", "")
if location and "," in location:
lon_str, lat_str = location.split(",")
gcj_lat = float(lat_str)
gcj_lon = float(lon_str)
lat, lon = gcj02_to_wgs84(gcj_lat, gcj_lon)
logger.info(
"Amap legacy geocode: GCJ-02(%.6f,%.6f) -> WGS-84(%.6f,%.6f)",
gcj_lat, gcj_lon, lat, lon,
)
return (lat, lon)
else:
logger.warning("Amap legacy geocode HTTP %d", resp.status)
infocode = data.get("infocode", "")
if infocode == "10012":
logger.debug("Amap legacy geocode: insufficient permissions (enterprise cert needed)")
else:
logger.warning("Amap legacy geocode error: %s (code=%s)", data.get("info", ""), infocode)
else:
logger.warning("Amap legacy geocode HTTP %d", resp.status)
except Exception as e:
logger.warning("Amap legacy geocode error: %s", e)
@@ -490,28 +511,26 @@ async def _reverse_geocode_amap(
url = "https://restapi.amap.com/v3/geocode/regeo"
try:
async with aiohttp.ClientSession() as session:
async with session.get(
url, params=params, timeout=aiohttp.ClientTimeout(total=5)
) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
if data.get("status") == "1":
regeocode = data.get("regeocode", {})
formatted = regeocode.get("formatted_address", "")
if formatted and formatted != "[]":
logger.info(
"Amap reverse geocode: %.6f,%.6f -> %s",
lat, lon, formatted,
)
return formatted
else:
logger.warning(
"Amap reverse geocode error: info=%s, infocode=%s",
data.get("info", ""), data.get("infocode", ""),
session = await _get_http_session()
async with session.get(url, params=params) as resp:
if resp.status == 200:
data = await resp.json(content_type=None)
if data.get("status") == "1":
regeocode = data.get("regeocode", {})
formatted = regeocode.get("formatted_address", "")
if formatted and formatted != "[]":
logger.info(
"Amap reverse geocode: %.6f,%.6f -> %s",
lat, lon, formatted,
)
return formatted
else:
logger.warning("Amap reverse geocode HTTP %d", resp.status)
logger.warning(
"Amap reverse geocode error: info=%s, infocode=%s",
data.get("info", ""), data.get("infocode", ""),
)
else:
logger.warning("Amap reverse geocode HTTP %d", resp.status)
except Exception as e:
logger.warning("Amap reverse geocode error: %s", e)