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:
269
app/geocoding.py
269
app/geocoding.py
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user