309 lines
11 KiB
Python
309 lines
11 KiB
Python
|
|
"""
|
||
|
|
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): 高德智能硬件定位
|
||
|
|
- Reverse geocoding (coords → address): 高德逆地理编码
|
||
|
|
"""
|
||
|
|
|
||
|
|
import hashlib
|
||
|
|
import logging
|
||
|
|
import math
|
||
|
|
from collections import OrderedDict
|
||
|
|
from typing import Optional
|
||
|
|
|
||
|
|
import aiohttp
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
from app.config import settings as _settings
|
||
|
|
|
||
|
|
AMAP_KEY: Optional[str] = _settings.AMAP_KEY
|
||
|
|
AMAP_SECRET: Optional[str] = _settings.AMAP_SECRET
|
||
|
|
|
||
|
|
_CACHE_MAX_SIZE = _settings.GEOCODING_CACHE_SIZE
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# WGS-84 → GCJ-02 coordinate conversion (server-side)
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
_A = 6378245.0
|
||
|
|
_EE = 0.00669342162296594
|
||
|
|
|
||
|
|
|
||
|
|
def _out_of_china(lat: float, lon: float) -> bool:
|
||
|
|
return not (73.66 < lon < 135.05 and 3.86 < lat < 53.55)
|
||
|
|
|
||
|
|
|
||
|
|
def _transform_lat(x: float, y: float) -> float:
|
||
|
|
ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * math.sqrt(abs(x))
|
||
|
|
ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
|
||
|
|
ret += (20.0 * math.sin(y * math.pi) + 40.0 * math.sin(y / 3.0 * math.pi)) * 2.0 / 3.0
|
||
|
|
ret += (160.0 * math.sin(y / 12.0 * math.pi) + 320.0 * math.sin(y * math.pi / 30.0)) * 2.0 / 3.0
|
||
|
|
return ret
|
||
|
|
|
||
|
|
|
||
|
|
def _transform_lon(x: float, y: float) -> float:
|
||
|
|
ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * math.sqrt(abs(x))
|
||
|
|
ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
|
||
|
|
ret += (20.0 * math.sin(x * math.pi) + 40.0 * math.sin(x / 3.0 * math.pi)) * 2.0 / 3.0
|
||
|
|
ret += (150.0 * math.sin(x / 12.0 * math.pi) + 300.0 * math.sin(x / 30.0 * math.pi)) * 2.0 / 3.0
|
||
|
|
return ret
|
||
|
|
|
||
|
|
|
||
|
|
def wgs84_to_gcj02(lat: float, lon: float) -> tuple[float, float]:
|
||
|
|
"""Convert WGS-84 to GCJ-02 (used by 高德)."""
|
||
|
|
if _out_of_china(lat, lon):
|
||
|
|
return (lat, lon)
|
||
|
|
d_lat = _transform_lat(lon - 105.0, lat - 35.0)
|
||
|
|
d_lon = _transform_lon(lon - 105.0, lat - 35.0)
|
||
|
|
rad_lat = lat / 180.0 * math.pi
|
||
|
|
magic = math.sin(rad_lat)
|
||
|
|
magic = 1 - _EE * magic * magic
|
||
|
|
sqrt_magic = math.sqrt(magic)
|
||
|
|
d_lat = (d_lat * 180.0) / ((_A * (1 - _EE)) / (magic * sqrt_magic) * math.pi)
|
||
|
|
d_lon = (d_lon * 180.0) / (_A / sqrt_magic * math.cos(rad_lat) * math.pi)
|
||
|
|
return (lat + d_lat, lon + d_lon)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# LRU Cache
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
|
||
|
|
class LRUCache(OrderedDict):
|
||
|
|
"""Simple LRU cache based on OrderedDict."""
|
||
|
|
|
||
|
|
def __init__(self, maxsize: int = _CACHE_MAX_SIZE):
|
||
|
|
super().__init__()
|
||
|
|
self._maxsize = maxsize
|
||
|
|
|
||
|
|
def get_cached(self, key):
|
||
|
|
if key in self:
|
||
|
|
self.move_to_end(key)
|
||
|
|
return self[key]
|
||
|
|
return None
|
||
|
|
|
||
|
|
def put(self, key, value):
|
||
|
|
if key in self:
|
||
|
|
self.move_to_end(key)
|
||
|
|
self[key] = value
|
||
|
|
while len(self) > self._maxsize:
|
||
|
|
self.popitem(last=False)
|
||
|
|
|
||
|
|
|
||
|
|
_cell_cache: LRUCache = LRUCache()
|
||
|
|
_wifi_cache: LRUCache = LRUCache()
|
||
|
|
_address_cache: LRUCache = LRUCache()
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# 高德数字签名 (AMAP_SECRET)
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def _amap_sign(params: dict) -> str:
|
||
|
|
"""Generate 高德 API digital signature (MD5)."""
|
||
|
|
if not AMAP_SECRET:
|
||
|
|
return ""
|
||
|
|
sorted_str = "&".join(f"{k}={params[k]}" for k in sorted(params.keys()))
|
||
|
|
raw = sorted_str + AMAP_SECRET
|
||
|
|
return hashlib.md5(raw.encode()).hexdigest()
|
||
|
|
|
||
|
|
|
||
|
|
# ===========================================================================
|
||
|
|
# Forward Geocoding: cell/WiFi → lat/lon
|
||
|
|
# ===========================================================================
|
||
|
|
|
||
|
|
|
||
|
|
async def geocode_location(
|
||
|
|
mcc: Optional[int] = None,
|
||
|
|
mnc: Optional[int] = None,
|
||
|
|
lac: Optional[int] = None,
|
||
|
|
cell_id: Optional[int] = None,
|
||
|
|
wifi_list: Optional[list[dict]] = None,
|
||
|
|
neighbor_cells: Optional[list[dict]] = None,
|
||
|
|
imei: Optional[str] = None,
|
||
|
|
) -> tuple[Optional[float], Optional[float]]:
|
||
|
|
"""
|
||
|
|
Convert cell tower and/or WiFi AP data to lat/lon.
|
||
|
|
|
||
|
|
Uses 高德智能硬件定位 API exclusively.
|
||
|
|
"""
|
||
|
|
# Check cache first
|
||
|
|
if 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
|
||
|
|
|
||
|
|
return (None, None)
|
||
|
|
|
||
|
|
|
||
|
|
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 = ""
|
||
|
|
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"
|
||
|
|
|
||
|
|
# Build nearbts (neighbor cells)
|
||
|
|
nearbts_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}")
|
||
|
|
|
||
|
|
# Build macs (WiFi APs): mac,signal,ssid
|
||
|
|
macs_parts = []
|
||
|
|
if wifi_list:
|
||
|
|
for ap in wifi_list:
|
||
|
|
mac = ap.get("mac", "").lower().replace(":", "")
|
||
|
|
signal = -(ap.get("signal", 0)) if ap.get("signal") else -70
|
||
|
|
ssid = ap.get("ssid", "")
|
||
|
|
macs_parts.append(f"{mac},{signal},{ssid}")
|
||
|
|
|
||
|
|
if not bts and not macs_parts:
|
||
|
|
return (None, None)
|
||
|
|
|
||
|
|
params = {"accesstype": "0", "imei": imei or _settings.GEOCODING_DEFAULT_IMEI, "key": AMAP_KEY}
|
||
|
|
if bts:
|
||
|
|
params["bts"] = bts
|
||
|
|
if nearbts_parts:
|
||
|
|
params["nearbts"] = "|".join(nearbts_parts)
|
||
|
|
if macs_parts:
|
||
|
|
params["macs"] = "|".join(macs_parts)
|
||
|
|
|
||
|
|
# Add digital signature
|
||
|
|
sig = _amap_sign(params)
|
||
|
|
if sig:
|
||
|
|
params["sig"] = sig
|
||
|
|
|
||
|
|
url = "https://apilocate.amap.com/position"
|
||
|
|
|
||
|
|
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" and data.get("result"):
|
||
|
|
result = data["result"]
|
||
|
|
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)
|
||
|
|
return (lat, lon)
|
||
|
|
else:
|
||
|
|
infocode = data.get("infocode", "")
|
||
|
|
if infocode == "10012":
|
||
|
|
logger.debug("Amap geocode: insufficient permissions (enterprise cert needed)")
|
||
|
|
else:
|
||
|
|
logger.warning("Amap geocode error: %s (code=%s)", data.get("info", ""), infocode)
|
||
|
|
else:
|
||
|
|
logger.warning("Amap geocode HTTP %d", resp.status)
|
||
|
|
except Exception as e:
|
||
|
|
logger.warning("Amap geocode error: %s", e)
|
||
|
|
|
||
|
|
return (None, None)
|
||
|
|
|
||
|
|
|
||
|
|
# ===========================================================================
|
||
|
|
# Reverse Geocoding: coordinates → address
|
||
|
|
# ===========================================================================
|
||
|
|
|
||
|
|
|
||
|
|
async def reverse_geocode(
|
||
|
|
lat: float, lon: float
|
||
|
|
) -> Optional[str]:
|
||
|
|
"""
|
||
|
|
Convert lat/lon (WGS-84) to a human-readable address.
|
||
|
|
|
||
|
|
Uses 高德逆地理编码 API exclusively.
|
||
|
|
"""
|
||
|
|
cache_key = (round(lat, 3), round(lon, 3))
|
||
|
|
cached = _address_cache.get_cached(cache_key)
|
||
|
|
if cached is not None:
|
||
|
|
return cached
|
||
|
|
|
||
|
|
if AMAP_KEY:
|
||
|
|
result = await _reverse_geocode_amap(lat, lon)
|
||
|
|
if result:
|
||
|
|
_address_cache.put(cache_key, result)
|
||
|
|
return result
|
||
|
|
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
async def _reverse_geocode_amap(
|
||
|
|
lat: float, lon: float
|
||
|
|
) -> Optional[str]:
|
||
|
|
"""
|
||
|
|
Use 高德逆地理编码 API.
|
||
|
|
|
||
|
|
API: https://restapi.amap.com/v3/geocode/regeo
|
||
|
|
Input: GCJ-02 coordinates (need to convert from WGS-84).
|
||
|
|
Free tier: 5,000 requests/day (personal), 1,000,000/day (enterprise).
|
||
|
|
"""
|
||
|
|
gcj_lat, gcj_lon = wgs84_to_gcj02(lat, lon)
|
||
|
|
|
||
|
|
params = {
|
||
|
|
"key": AMAP_KEY,
|
||
|
|
"location": f"{gcj_lon:.6f},{gcj_lat:.6f}",
|
||
|
|
"extensions": "base",
|
||
|
|
"output": "json",
|
||
|
|
}
|
||
|
|
|
||
|
|
sig = _amap_sign(params)
|
||
|
|
if sig:
|
||
|
|
params["sig"] = sig
|
||
|
|
|
||
|
|
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", ""),
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
logger.warning("Amap reverse geocode HTTP %d", resp.status)
|
||
|
|
except Exception as e:
|
||
|
|
logger.warning("Amap reverse geocode error: %s", e)
|
||
|
|
|
||
|
|
return None
|