feat: 高德IoT v5 API升级、电子围栏管理、设备绑定自动考勤

- 前向地理编码升级为高德IoT v5 API (POST restapi.amap.com/v5/position/IoT)
- 修复LBS定位偏差: 添加network=LTE参数区分4G/2G, bts格式补充cage字段
- 新增电子围栏管理模块 (circle/polygon/rectangle), 支持地图绘制和POI搜索
- 新增设备-围栏多对多绑定 (DeviceFenceBinding/DeviceFenceState)
- 围栏自动考勤引擎 (fence_checker.py): haversine距离、ray-casting多边形判定、容差机制、防抖
- TCP位置上报自动检测围栏进出, 生成考勤记录并WebSocket广播
- 前端围栏页面: 绑定设备弹窗、POI搜索定位、左侧围栏面板
- 新增fence_attendance WebSocket topic

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

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-03-27 13:04:11 +00:00
parent cde5146bfe
commit 1d06cc5415
17 changed files with 2303 additions and 187 deletions

View File

@@ -3,7 +3,7 @@ Geocoding service - Convert cell tower / WiFi AP data to lat/lon coordinates,
and reverse geocode coordinates to addresses.
All services use 高德 (Amap) API exclusively.
- Forward geocoding (cell/WiFi → coords): 高德智能硬件定位
- Forward geocoding (cell/WiFi → coords): 高德 IoT 定位 v5 API
- Reverse geocoding (coords → address): 高德逆地理编码
"""
@@ -21,6 +21,8 @@ from app.config import settings as _settings
AMAP_KEY: Optional[str] = _settings.AMAP_KEY
AMAP_SECRET: Optional[str] = _settings.AMAP_SECRET
AMAP_HARDWARE_KEY: Optional[str] = _settings.AMAP_HARDWARE_KEY
AMAP_HARDWARE_SECRET: Optional[str] = _settings.AMAP_HARDWARE_SECRET
_CACHE_MAX_SIZE = _settings.GEOCODING_CACHE_SIZE
@@ -68,6 +70,14 @@ def wgs84_to_gcj02(lat: float, lon: float) -> tuple[float, float]:
return (lat + d_lat, lon + d_lon)
def gcj02_to_wgs84(lat: float, lon: float) -> tuple[float, float]:
"""Convert GCJ-02 to WGS-84 (reverse of wgs84_to_gcj02)."""
if _out_of_china(lat, lon):
return (lat, lon)
gcj_lat, gcj_lon = wgs84_to_gcj02(lat, lon)
return (lat * 2 - gcj_lat, lon * 2 - gcj_lon)
# ---------------------------------------------------------------------------
# LRU Cache
# ---------------------------------------------------------------------------
@@ -125,52 +135,229 @@ async def geocode_location(
wifi_list: Optional[list[dict]] = None,
neighbor_cells: Optional[list[dict]] = None,
imei: Optional[str] = None,
location_type: Optional[str] = None,
) -> tuple[Optional[float], Optional[float]]:
"""
Convert cell tower and/or WiFi AP data to lat/lon.
Uses 高德智能硬件定位 API exclusively.
Uses 高德 IoT 定位 v5 API (restapi.amap.com/v5/position/IoT).
Falls back to legacy API (apilocate.amap.com/position) if v5 fails.
location_type: "lbs"/"wifi" for 2G(GSM), "lbs_4g"/"wifi_4g" for 4G(LTE).
"""
# Check cache first
if mcc is not None and lac is not None and cell_id is not None:
if wifi_list:
wifi_cache_key = tuple(sorted((ap.get("mac", "") for ap in wifi_list)))
cached = _wifi_cache.get_cached(wifi_cache_key)
if cached is not None:
return cached
elif mcc is not None and lac is not None and cell_id is not None:
cache_key = (mcc, mnc or 0, lac, cell_id)
cached = _cell_cache.get_cached(cache_key)
if cached is not None:
return cached
if AMAP_KEY:
result = await _geocode_amap(mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, imei=imei)
if result[0] is not None:
if mcc is not None and lac is not None and cell_id is not None:
_cell_cache.put((mcc, mnc or 0, lac, cell_id), result)
return result
api_key = AMAP_KEY
if not api_key:
return (None, None)
return (None, None)
# Determine network type from location_type
is_4g = location_type in ("lbs_4g", "wifi_4g", "gps_4g")
# Try v5 API first (POST restapi.amap.com/v5/position/IoT)
result = await _geocode_amap_v5(
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells,
imei=imei, api_key=api_key, is_4g=is_4g,
)
# Fallback to legacy API if v5 fails and hardware key is available
if result[0] is None and AMAP_HARDWARE_KEY:
result = await _geocode_amap_legacy(
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells,
imei=imei, api_key=AMAP_HARDWARE_KEY,
)
if result[0] is not None:
if wifi_list:
_wifi_cache.put(wifi_cache_key, result)
elif mcc is not None and lac is not None and cell_id is not None:
_cell_cache.put((mcc, mnc or 0, lac, cell_id), result)
return result
async def _geocode_amap(
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, *, imei: Optional[str] = None
) -> tuple[Optional[float], Optional[float]]:
"""
Use 高德智能硬件定位 API (apilocate.amap.com/position).
Returns coordinates (高德 returns GCJ-02).
"""
# Build bts (base station) parameter: mcc,mnc,lac,cellid,signal
bts = ""
def _build_bts(mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int]) -> str:
"""Build bts (base station) parameter: mcc,mnc,lac,cellid,signal,cage"""
if mcc is not None and lac is not None and cell_id is not None:
bts = f"{mcc},{mnc or 0},{lac},{cell_id},-65"
return f"{mcc},{mnc or 0},{lac},{cell_id},-65,0"
return ""
# Build nearbts (neighbor cells)
nearbts_parts = []
def _build_nearbts(
neighbor_cells: Optional[list[dict]], mcc: Optional[int], mnc: Optional[int]
) -> list[str]:
"""Build nearbts (neighbor cell) parts."""
parts = []
if neighbor_cells:
for nc in neighbor_cells:
nc_lac = nc.get("lac", 0)
nc_cid = nc.get("cell_id", 0)
nc_signal = -(nc.get("rssi", 0)) if nc.get("rssi") else -80
nearbts_parts.append(f"{mcc or 460},{mnc or 0},{nc_lac},{nc_cid},{nc_signal}")
parts.append(f"{mcc or 460},{mnc or 0},{nc_lac},{nc_cid},{nc_signal},0")
return parts
# Build macs (WiFi APs): mac,signal,ssid
def _build_wifi_parts(wifi_list: Optional[list[dict]]) -> list[str]:
"""Build WiFi MAC parts: mac,signal,ssid,fresh"""
parts = []
if wifi_list:
for ap in wifi_list:
mac = ap.get("mac", "")
# v5 API requires colon-separated lowercase MAC
if ":" not in mac:
# Convert raw hex to colon-separated
mac_clean = mac.lower().replace("-", "")
if len(mac_clean) == 12:
mac = ":".join(mac_clean[i:i+2] for i in range(0, 12, 2))
else:
mac = mac.lower()
else:
mac = mac.lower()
signal = -(ap.get("signal", 0)) if ap.get("signal") else -70
ssid = ap.get("ssid", "")
parts.append(f"{mac},{signal},{ssid},0")
return parts
def _select_mmac(wifi_parts: list[str]) -> tuple[str, list[str]]:
"""Select strongest WiFi AP as mmac (connected WiFi), rest as macs.
v5 API requires mmac when accesstype=2.
Returns (mmac_str, remaining_macs_parts).
"""
if not wifi_parts:
return ("", [])
# Find strongest signal (most negative = weakest, so max of negative values)
# Parts format: "mac,signal,ssid,fresh"
best_idx = 0
best_signal = -999
for i, part in enumerate(wifi_parts):
fields = part.split(",")
if len(fields) >= 2:
try:
sig = int(fields[1])
if sig > best_signal:
best_signal = sig
best_idx = i
except ValueError:
pass
mmac = wifi_parts[best_idx]
remaining = [p for i, p in enumerate(wifi_parts) if i != best_idx]
return (mmac, remaining)
async def _geocode_amap_v5(
mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int],
wifi_list: Optional[list[dict]], neighbor_cells: Optional[list[dict]],
*, imei: Optional[str] = None, api_key: str, is_4g: bool = False,
) -> tuple[Optional[float], Optional[float]]:
"""
Use 高德 IoT 定位 v5 API (POST restapi.amap.com/v5/position/IoT).
Key differences from legacy:
- POST method, key in URL params, data in body
- accesstype: 0=未知, 1=移动网络, 2=WiFi
- WiFi requires mmac (connected WiFi) + macs (nearby, 2-30)
- network: GSM(default)/LTE/WCDMA/NR — critical for 4G accuracy
- diu replaces imei
- No digital signature needed
- show_fields can return address directly
"""
bts = _build_bts(mcc, mnc, lac, cell_id)
nearbts_parts = _build_nearbts(neighbor_cells, mcc, mnc)
wifi_parts = _build_wifi_parts(wifi_list)
if not bts and not wifi_parts:
return (None, None)
# Determine accesstype: 2=WiFi (when we have WiFi data), 1=mobile network
has_wifi = len(wifi_parts) >= 2 # v5 requires 2+ WiFi APs
accesstype = "2" if has_wifi else "1"
# Build POST body
body: dict[str, str] = {
"accesstype": accesstype,
"cdma": "0",
"network": "LTE" if is_4g else "GSM",
"diu": imei or _settings.GEOCODING_DEFAULT_IMEI,
"show_fields": "formatted_address",
}
if bts:
body["bts"] = bts
if nearbts_parts:
body["nearbts"] = "|".join(nearbts_parts)
if has_wifi:
mmac, remaining_macs = _select_mmac(wifi_parts)
body["mmac"] = mmac
if remaining_macs:
body["macs"] = "|".join(remaining_macs)
elif wifi_parts:
# Less than 2 WiFi APs: include as macs anyway, use accesstype=1
body["macs"] = "|".join(wifi_parts)
url = f"https://restapi.amap.com/v5/position/IoT?key={api_key}"
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)
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)",
data.get("info", ""), infocode,
)
else:
logger.warning("Amap v5 geocode HTTP %d", resp.status)
except Exception as e:
logger.warning("Amap v5 geocode error: %s", e)
return (None, None)
async def _geocode_amap_legacy(
mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int],
wifi_list: Optional[list[dict]], neighbor_cells: Optional[list[dict]],
*, imei: Optional[str] = None, api_key: str,
) -> tuple[Optional[float], Optional[float]]:
"""
Legacy 高德智能硬件定位 API (GET apilocate.amap.com/position).
Used as fallback when v5 API fails.
"""
bts = _build_bts(mcc, mnc, lac, cell_id)
nearbts_parts = _build_nearbts(neighbor_cells, mcc, mnc)
# Build macs (legacy format without fresh field)
macs_parts = []
if wifi_list:
for ap in wifi_list:
@@ -182,7 +369,11 @@ async def _geocode_amap(
if not bts and not macs_parts:
return (None, None)
params = {"accesstype": "0", "imei": imei or _settings.GEOCODING_DEFAULT_IMEI, "key": AMAP_KEY}
params: dict[str, str] = {
"accesstype": "0",
"imei": imei or _settings.GEOCODING_DEFAULT_IMEI,
"key": api_key,
}
if bts:
params["bts"] = bts
if nearbts_parts:
@@ -190,9 +381,11 @@ async def _geocode_amap(
if macs_parts:
params["macs"] = "|".join(macs_parts)
# Add digital signature
sig = _amap_sign(params)
if sig:
# Only sign if using a key that has its own secret
hw_secret = AMAP_HARDWARE_SECRET
if hw_secret:
sorted_str = "&".join(f"{k}={params[k]}" for k in sorted(params.keys()))
sig = hashlib.md5((sorted_str + hw_secret).encode()).hexdigest()
params["sig"] = sig
url = "https://apilocate.amap.com/position"
@@ -209,20 +402,24 @@ async def _geocode_amap(
location = result.get("location", "")
if location and "," in location:
lon_str, lat_str = location.split(",")
lat = float(lat_str)
lon = float(lon_str)
logger.info("Amap geocode: lat=%.6f, lon=%.6f", lat, lon)
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 geocode: insufficient permissions (enterprise cert needed)")
logger.debug("Amap legacy geocode: insufficient permissions (enterprise cert needed)")
else:
logger.warning("Amap geocode error: %s (code=%s)", data.get("info", ""), infocode)
logger.warning("Amap legacy geocode error: %s (code=%s)", data.get("info", ""), infocode)
else:
logger.warning("Amap geocode HTTP %d", resp.status)
logger.warning("Amap legacy geocode HTTP %d", resp.status)
except Exception as e:
logger.warning("Amap geocode error: %s", e)
logger.warning("Amap legacy geocode error: %s", e)
return (None, None)